/* schedule.js — Gantt chart with row virtualization */ let ganttData = { entries: [], dependencies: [], milestones: [] }; let projects = []; let phases = []; let templates = []; let allRows = []; // flat row data for virtualization let collapseState = {}; // { projectCode: bool } let ncCache = {}; // { projectId: [issues] } const ROW_HEIGHT = 32; const BUFFER_ROWS = 5; const DAY_WIDTHS = { month: 24, quarter: 12, year: 3 }; let currentZoom = 'quarter'; let currentYear = new Date().getFullYear(); let canEdit = false; /* ===== Init ===== */ document.addEventListener('DOMContentLoaded', async () => { const ok = await initAuth(); if (!ok) return; document.querySelector('.fade-in').classList.add('visible'); // Check edit permission (support_team+) const role = currentUser?.role || ''; canEdit = ['support_team', 'admin', 'system', 'system admin'].includes(role); if (canEdit) { document.getElementById('btnAddEntry').classList.remove('hidden'); document.getElementById('btnBatchAdd').classList.remove('hidden'); document.getElementById('btnAddMilestone').classList.remove('hidden'); } // Load collapse state try { const saved = localStorage.getItem('gantt_collapse'); if (saved) collapseState = JSON.parse(saved); } catch {} // Year select const sel = document.getElementById('yearSelect'); for (let y = currentYear - 2; y <= currentYear + 2; y++) { const opt = document.createElement('option'); opt.value = y; opt.textContent = y; if (y === currentYear) opt.selected = true; sel.appendChild(opt); } sel.addEventListener('change', () => { currentYear = parseInt(sel.value); loadGantt(); }); // Zoom buttons document.querySelectorAll('.zoom-btn').forEach(btn => { btn.addEventListener('click', () => { document.querySelectorAll('.zoom-btn').forEach(b => b.classList.remove('active')); btn.classList.add('active'); currentZoom = btn.dataset.zoom; renderGantt(); }); }); // Toolbar buttons document.getElementById('btnAddEntry').addEventListener('click', () => openEntryModal()); document.getElementById('btnBatchAdd').addEventListener('click', () => openBatchModal()); document.getElementById('btnAddMilestone').addEventListener('click', () => openMilestoneModal()); await loadMasterData(); await loadGantt(); }); async function loadMasterData() { try { const [projRes, phaseRes, tmplRes] = await Promise.all([ api('/projects'), api('/schedule/phases'), api('/schedule/templates') ]); projects = projRes.data || []; phases = phaseRes.data || []; templates = tmplRes.data || []; } catch (err) { showToast('마스터 데이터 로드 실패', 'error'); } } async function loadGantt() { try { const res = await api(`/schedule/entries/gantt?year=${currentYear}`); ganttData = res.data; // Preload NC data const projectIds = [...new Set(ganttData.entries.map(e => e.project_id))]; await Promise.all(projectIds.map(async pid => { try { const ncRes = await api(`/schedule/nonconformance?project_id=${pid}`); ncCache[pid] = ncRes.data || []; } catch { ncCache[pid] = []; } })); renderGantt(); } catch (err) { showToast('공정표 데이터 로드 실패: ' + err.message, 'error'); } } /* ===== Build flat row array ===== */ function buildRows() { allRows = []; // Group entries by project, then by phase const byProject = {}; ganttData.entries.forEach(e => { if (!byProject[e.project_code]) byProject[e.project_code] = { project_id: e.project_id, project_name: e.project_name, code: e.project_code, phases: {} }; const p = byProject[e.project_code]; if (!p.phases[e.phase_name]) p.phases[e.phase_name] = { phase_id: e.phase_id, color: e.phase_color, order: e.phase_order, entries: [] }; p.phases[e.phase_name].entries.push(e); }); // Also add milestones-only projects ganttData.milestones.forEach(m => { if (!byProject[m.project_code]) byProject[m.project_code] = { project_id: m.project_id, project_name: m.project_name, code: m.project_code, phases: {} }; }); const sortedProjects = Object.values(byProject).sort((a, b) => a.code.localeCompare(b.code)); for (const proj of sortedProjects) { const collapsed = collapseState[proj.code] === true; allRows.push({ type: 'project', code: proj.code, label: `${proj.code} ${proj.project_name}`, project_id: proj.project_id, collapsed }); if (!collapsed) { const sortedPhases = Object.entries(proj.phases).sort((a, b) => a[1].order - b[1].order); for (const [phaseName, phaseData] of sortedPhases) { allRows.push({ type: 'phase', label: phaseName, color: phaseData.color }); for (const entry of phaseData.entries) { allRows.push({ type: 'task', entry, color: phaseData.color }); } } // Milestones for this project const projMilestones = ganttData.milestones.filter(m => m.project_id === proj.project_id); if (projMilestones.length > 0) { allRows.push({ type: 'milestone-header', label: '◆ 마일스톤', milestones: projMilestones }); } // NC row const ncList = ncCache[proj.project_id] || []; if (ncList.length > 0) { allRows.push({ type: 'nc', label: `⚠ 부적합 (${ncList.length})`, project_id: proj.project_id, count: ncList.length }); } } } } /* ===== Render ===== */ function renderGantt() { buildRows(); const container = document.getElementById('ganttContainer'); const wrapper = document.getElementById('ganttWrapper'); const dayWidth = DAY_WIDTHS[currentZoom]; // Calculate total days in year const yearStart = new Date(currentYear, 0, 1); const yearEnd = new Date(currentYear, 11, 31); const totalDays = Math.ceil((yearEnd - yearStart) / 86400000) + 1; const timelineWidth = totalDays * dayWidth; container.style.setProperty('--day-width', dayWidth + 'px'); container.style.width = (250 + timelineWidth) + 'px'; // Build month header let headerHtml = '
프로젝트 / 단계 / 작업
'; for (let m = 0; m < 12; m++) { const daysInMonth = new Date(currentYear, m + 1, 0).getDate(); const monthWidth = daysInMonth * dayWidth; const monthNames = ['1월','2월','3월','4월','5월','6월','7월','8월','9월','10월','11월','12월']; headerHtml += `
${monthNames[m]}
`; } headerHtml += '
'; // Virtual scroll container const totalHeight = allRows.length * ROW_HEIGHT; let rowsHtml = `
`; container.innerHTML = headerHtml + rowsHtml; // Today marker const today = new Date(); if (today.getFullYear() === currentYear) { const todayOffset = dayOfYear(today) - 1; const markerLeft = 250 + todayOffset * dayWidth; const marker = document.createElement('div'); marker.className = 'today-marker'; marker.style.left = markerLeft + 'px'; container.appendChild(marker); } // Setup virtual scroll const virtualBody = document.getElementById('ganttVirtualBody'); const onScroll = () => renderVisibleRows(wrapper, virtualBody, dayWidth, totalDays); wrapper.addEventListener('scroll', onScroll); renderVisibleRows(wrapper, virtualBody, dayWidth, totalDays); // Scroll to today if (today.getFullYear() === currentYear) { const todayOffset = dayOfYear(today) - 1; const scrollTo = Math.max(0, todayOffset * dayWidth - wrapper.clientWidth / 2 + 250); wrapper.scrollLeft = scrollTo; } } function renderVisibleRows(wrapper, virtualBody, dayWidth, totalDays) { const scrollTop = wrapper.scrollTop - 30; // account for header const viewHeight = wrapper.clientHeight; const startIdx = Math.max(0, Math.floor(scrollTop / ROW_HEIGHT) - BUFFER_ROWS); const endIdx = Math.min(allRows.length, Math.ceil((scrollTop + viewHeight) / ROW_HEIGHT) + BUFFER_ROWS); let html = ''; for (let i = startIdx; i < endIdx; i++) { const row = allRows[i]; const top = i * ROW_HEIGHT; html += renderRow(row, top, dayWidth, totalDays); } virtualBody.innerHTML = html; } function renderRow(row, top, dayWidth, totalDays) { const style = `position:absolute;top:${top}px;width:100%;height:${ROW_HEIGHT}px;`; if (row.type === 'project') { const arrowClass = row.collapsed ? 'collapsed' : ''; return `
${escapeHtml(row.label)}
`; } if (row.type === 'phase') { return `
${escapeHtml(row.label)}
`; } if (row.type === 'task') { const e = row.entry; const bar = calcBar(e.start_date, e.end_date, dayWidth); const statusColors = { planned: '0.6', in_progress: '0.85', completed: '1', delayed: '0.9' }; const opacity = statusColors[e.status] || '0.7'; const barHtml = bar ? `
${bar.width > 50 ? `
${escapeHtml(e.task_name)}
` : ''}
` : ''; return `
${escapeHtml(e.task_name)}${e.assignee ? ` (${escapeHtml(e.assignee)})` : ''}
${barHtml}
`; } if (row.type === 'milestone-header') { let markers = ''; for (const m of row.milestones) { const pos = calcPos(m.milestone_date, dayWidth); if (pos !== null) { const mColor = m.status === 'completed' ? '#10B981' : m.status === 'missed' ? '#EF4444' : '#7C3AED'; markers += `
`; } } return `
${escapeHtml(row.label)}
${markers}
`; } if (row.type === 'nc') { // Place NC badges by date const ncList = ncCache[row.project_id] || []; let badges = ''; const byMonth = {}; ncList.forEach(nc => { const d = nc.report_date ? new Date(nc.report_date) : null; if (d && d.getFullYear() === currentYear) { const m = d.getMonth(); byMonth[m] = (byMonth[m] || 0) + 1; } }); for (const [m, cnt] of Object.entries(byMonth)) { const monthStart = new Date(currentYear, parseInt(m), 15); const pos = calcPos(monthStart, dayWidth); if (pos !== null) { badges += `
${cnt}
`; } } return `
${escapeHtml(row.label)}
${badges}
`; } return ''; } /* ===== Helpers ===== */ function dayOfYear(d) { const start = new Date(d.getFullYear(), 0, 1); return Math.ceil((d - start) / 86400000) + 1; } function calcBar(startStr, endStr, dayWidth) { const s = new Date(startStr); const e = new Date(endStr); if (s.getFullYear() > currentYear || e.getFullYear() < currentYear) return null; const yearStart = new Date(currentYear, 0, 1); const yearEnd = new Date(currentYear, 11, 31); const clampStart = s < yearStart ? yearStart : s; const clampEnd = e > yearEnd ? yearEnd : e; const startDay = Math.ceil((clampStart - yearStart) / 86400000); const endDay = Math.ceil((clampEnd - yearStart) / 86400000) + 1; return { left: startDay * dayWidth, width: Math.max((endDay - startDay) * dayWidth, 4) }; } function calcPos(dateStr, dayWidth) { const d = new Date(dateStr); if (d.getFullYear() !== currentYear) return null; const yearStart = new Date(currentYear, 0, 1); const offset = Math.ceil((d - yearStart) / 86400000); return offset * dayWidth; } /* ===== Interactions ===== */ function toggleProject(code) { collapseState[code] = !collapseState[code]; localStorage.setItem('gantt_collapse', JSON.stringify(collapseState)); renderGantt(); } function showBarDetail(entryId) { const entry = ganttData.entries.find(e => e.entry_id === entryId); if (!entry) return; const popup = document.getElementById('barDetailPopup'); document.getElementById('barDetailTitle').textContent = entry.task_name; const statusLabels = { planned: '계획', in_progress: '진행중', completed: '완료', delayed: '지연', cancelled: '취소' }; document.getElementById('barDetailContent').innerHTML = `
프로젝트${escapeHtml(entry.project_code)} ${escapeHtml(entry.project_name)}
공정 단계${escapeHtml(entry.phase_name)}
기간${formatDate(entry.start_date)} ~ ${formatDate(entry.end_date)}
진행률${entry.progress}%
상태${statusLabels[entry.status] || entry.status}
${entry.assignee ? `
담당자${escapeHtml(entry.assignee)}
` : ''} ${entry.notes ? `
메모: ${escapeHtml(entry.notes)}
` : ''}
`; let actions = ''; if (canEdit) { actions = ``; } document.getElementById('barDetailActions').innerHTML = actions; popup.classList.remove('hidden'); } function showMilestoneDetail(milestoneId) { const m = ganttData.milestones.find(ms => ms.milestone_id === milestoneId); if (!m) return; const popup = document.getElementById('barDetailPopup'); const typeLabels = { deadline: '납기', review: '검토', inspection: '검사', delivery: '출하', meeting: '회의', other: '기타' }; const statusLabels = { upcoming: '예정', completed: '완료', missed: '미달성' }; document.getElementById('barDetailTitle').textContent = '◆ ' + m.milestone_name; document.getElementById('barDetailContent').innerHTML = `
프로젝트${escapeHtml(m.project_code)} ${escapeHtml(m.project_name)}
날짜${formatDate(m.milestone_date)}
유형${typeLabels[m.milestone_type] || m.milestone_type}
상태${statusLabels[m.status] || m.status}
${m.notes ? `
메모: ${escapeHtml(m.notes)}
` : ''}
`; let actions = ''; if (canEdit) { actions = ``; } document.getElementById('barDetailActions').innerHTML = actions; popup.classList.remove('hidden'); } function showNcPopup(projectId) { const list = ncCache[projectId] || []; const proj = projects.find(p => p.project_id === projectId); document.getElementById('ncPopupTitle').textContent = `부적합 현황 - ${proj ? proj.job_no : ''}`; const statusLabels = { reported: '신고', received: '접수', reviewing: '검토중', in_progress: '처리중', completed: '완료' }; let html = ''; if (list.length === 0) { html = '

부적합 내역이 없습니다.

'; } else { html = ''; for (const nc of list) { html += ``; } html += '
일자분류내용상태
${formatDate(nc.report_date)} ${escapeHtml(nc.category || '-')} ${escapeHtml(nc.description || '-')} ${statusLabels[nc.review_status] || nc.review_status}
'; } document.getElementById('ncPopupContent').innerHTML = html; document.getElementById('ncPopup').classList.remove('hidden'); } /* ===== Entry Modal ===== */ function openEntryModal(editId) { const modal = document.getElementById('entryModal'); const isEdit = !!editId; document.getElementById('entryModalTitle').textContent = isEdit ? '공정표 항목 수정' : '공정표 항목 추가'; // Populate dropdowns populateSelect('entryProject', projects, 'project_id', p => `${p.job_no} ${p.project_name}`); populateSelect('entryPhase', phases, 'phase_id', p => p.phase_name); // Template select (populated on phase change) const phaseSelect = document.getElementById('entryPhase'); phaseSelect.addEventListener('change', () => loadTemplateOptions('entryTemplate', phaseSelect.value)); if (phases.length > 0) loadTemplateOptions('entryTemplate', phaseSelect.value); // Template → task name document.getElementById('entryTemplate').addEventListener('change', function() { if (this.value) { const tmpl = templates.find(t => t.template_id === parseInt(this.value)); if (tmpl) { document.getElementById('entryTaskName').value = tmpl.task_name; // Auto-fill duration const startDate = document.getElementById('entryStartDate').value; if (startDate && tmpl.default_duration_days) { const end = new Date(startDate); end.setDate(end.getDate() + tmpl.default_duration_days); document.getElementById('entryEndDate').value = end.toISOString().split('T')[0]; } } } }); // Dependencies (all entries for the selected project) const depSelect = document.getElementById('entryDependencies'); depSelect.innerHTML = ''; if (isEdit) { const entry = ganttData.entries.find(e => e.entry_id === editId); if (!entry) return; document.getElementById('entryId').value = editId; document.getElementById('entryProject').value = entry.project_id; document.getElementById('entryPhase').value = entry.phase_id; document.getElementById('entryTaskName').value = entry.task_name; document.getElementById('entryStartDate').value = formatDate(entry.start_date); document.getElementById('entryEndDate').value = formatDate(entry.end_date); document.getElementById('entryAssignee').value = entry.assignee || ''; document.getElementById('entryProgress').value = entry.progress; document.getElementById('entryStatus').value = entry.status; document.getElementById('entryNotes').value = entry.notes || ''; // Load dependencies const projEntries = ganttData.entries.filter(e => e.project_id === entry.project_id && e.entry_id !== editId); const deps = ganttData.dependencies.filter(d => d.entry_id === editId).map(d => d.depends_on_entry_id); projEntries.forEach(e => { const opt = document.createElement('option'); opt.value = e.entry_id; opt.textContent = `${e.phase_name} > ${e.task_name}`; opt.selected = deps.includes(e.entry_id); depSelect.appendChild(opt); }); } else { document.getElementById('entryId').value = ''; document.getElementById('entryTaskName').value = ''; document.getElementById('entryStartDate').value = ''; document.getElementById('entryEndDate').value = ''; document.getElementById('entryAssignee').value = ''; document.getElementById('entryProgress').value = '0'; document.getElementById('entryStatus').value = 'planned'; document.getElementById('entryNotes').value = ''; } modal.classList.remove('hidden'); } function closeEntryModal() { document.getElementById('entryModal').classList.add('hidden'); } async function saveEntry() { const entryId = document.getElementById('entryId').value; const taskName = document.getElementById('entryTaskName').value.trim(); if (!taskName) { showToast('작업명을 입력해주세요.', 'error'); return; } const data = { project_id: document.getElementById('entryProject').value, phase_id: document.getElementById('entryPhase').value, task_name: taskName, start_date: document.getElementById('entryStartDate').value, end_date: document.getElementById('entryEndDate').value, assignee: document.getElementById('entryAssignee').value || null, progress: parseInt(document.getElementById('entryProgress').value) || 0, status: document.getElementById('entryStatus').value, notes: document.getElementById('entryNotes').value || null }; try { if (entryId) { await api(`/schedule/entries/${entryId}`, { method: 'PUT', body: JSON.stringify(data) }); // Update dependencies const depSelect = document.getElementById('entryDependencies'); const selectedDeps = Array.from(depSelect.selectedOptions).map(o => parseInt(o.value)); const existingDeps = ganttData.dependencies.filter(d => d.entry_id === parseInt(entryId)).map(d => d.depends_on_entry_id); // Add new for (const depId of selectedDeps) { if (!existingDeps.includes(depId)) { await api(`/schedule/entries/${entryId}/dependencies`, { method: 'POST', body: JSON.stringify({ depends_on_entry_id: depId }) }); } } // Remove old for (const depId of existingDeps) { if (!selectedDeps.includes(depId)) { await api(`/schedule/entries/${entryId}/dependencies/${depId}`, { method: 'DELETE' }); } } showToast('공정표 항목이 수정되었습니다.'); } else { const res = await api('/schedule/entries', { method: 'POST', body: JSON.stringify(data) }); // Add dependencies for new entry const depSelect = document.getElementById('entryDependencies'); const selectedDeps = Array.from(depSelect.selectedOptions).map(o => parseInt(o.value)); for (const depId of selectedDeps) { await api(`/schedule/entries/${res.data.entry_id}/dependencies`, { method: 'POST', body: JSON.stringify({ depends_on_entry_id: depId }) }); } showToast('공정표 항목이 추가되었습니다.'); } closeEntryModal(); await loadGantt(); } catch (err) { showToast(err.message, 'error'); } } /* ===== Batch Modal ===== */ function openBatchModal() { populateSelect('batchProject', projects, 'project_id', p => `${p.job_no} ${p.project_name}`); populateSelect('batchPhase', phases, 'phase_id', p => p.phase_name); document.getElementById('batchStartDate').value = ''; document.getElementById('batchTemplateList').innerHTML = ''; loadBatchTemplates(); document.getElementById('batchModal').classList.remove('hidden'); } function closeBatchModal() { document.getElementById('batchModal').classList.add('hidden'); } function loadBatchTemplates() { const phaseId = parseInt(document.getElementById('batchPhase').value); const filtered = templates.filter(t => t.phase_id === phaseId); const list = document.getElementById('batchTemplateList'); if (filtered.length === 0) { list.innerHTML = '

해당 단계에 템플릿이 없습니다.

'; return; } list.innerHTML = filtered.map((t, i) => `
${escapeHtml(t.task_name)} ${t.default_duration_days}일 ~
`).join(''); recalcBatchDates(); } function recalcBatchDates() { const baseStart = document.getElementById('batchStartDate').value; if (!baseStart) return; const phaseId = parseInt(document.getElementById('batchPhase').value); const filtered = templates.filter(t => t.phase_id === phaseId); let cursor = new Date(baseStart); for (const t of filtered) { const startEl = document.getElementById(`btmpl_start_${t.template_id}`); const endEl = document.getElementById(`btmpl_end_${t.template_id}`); if (startEl && endEl) { startEl.value = cursor.toISOString().split('T')[0]; const endDate = new Date(cursor); endDate.setDate(endDate.getDate() + t.default_duration_days); endEl.value = endDate.toISOString().split('T')[0]; cursor = new Date(endDate); } } } async function saveBatchEntries() { const projectId = document.getElementById('batchProject').value; const phaseId = document.getElementById('batchPhase').value; const filtered = templates.filter(t => t.phase_id === parseInt(phaseId)); const entries = []; for (const t of filtered) { const cb = document.getElementById(`btmpl_${t.template_id}`); if (!cb || !cb.checked) continue; const startDate = document.getElementById(`btmpl_start_${t.template_id}`)?.value; const endDate = document.getElementById(`btmpl_end_${t.template_id}`)?.value; if (!startDate || !endDate) { showToast(`${t.task_name}의 날짜를 입력해주세요.`, 'error'); return; } entries.push({ task_name: t.task_name, start_date: startDate, end_date: endDate, display_order: t.display_order }); } if (entries.length === 0) { showToast('추가할 항목이 없습니다.', 'error'); return; } try { await api('/schedule/entries/batch', { method: 'POST', body: JSON.stringify({ project_id: projectId, phase_id: phaseId, entries }) }); showToast(`${entries.length}개 항목이 일괄 추가되었습니다.`); closeBatchModal(); await loadGantt(); } catch (err) { showToast(err.message, 'error'); } } /* ===== Milestone Modal ===== */ function openMilestoneModal(editId) { const modal = document.getElementById('milestoneModal'); const isEdit = !!editId; document.getElementById('milestoneModalTitle').textContent = isEdit ? '마일스톤 수정' : '마일스톤 추가'; populateSelect('milestoneProject', projects, 'project_id', p => `${p.job_no} ${p.project_name}`); if (isEdit) { const m = ganttData.milestones.find(ms => ms.milestone_id === editId); if (!m) return; document.getElementById('milestoneId').value = editId; document.getElementById('milestoneProject').value = m.project_id; document.getElementById('milestoneName').value = m.milestone_name; document.getElementById('milestoneDate').value = formatDate(m.milestone_date); document.getElementById('milestoneType').value = m.milestone_type; document.getElementById('milestoneStatus').value = m.status; document.getElementById('milestoneNotes').value = m.notes || ''; // Load entry options for project loadMilestoneEntries(m.project_id, m.entry_id); } else { document.getElementById('milestoneId').value = ''; document.getElementById('milestoneName').value = ''; document.getElementById('milestoneDate').value = ''; document.getElementById('milestoneType').value = 'deadline'; document.getElementById('milestoneStatus').value = 'upcoming'; document.getElementById('milestoneNotes').value = ''; document.getElementById('milestoneEntry').innerHTML = ''; } // Update entry list on project change document.getElementById('milestoneProject').onchange = function() { loadMilestoneEntries(this.value); }; modal.classList.remove('hidden'); } function loadMilestoneEntries(projectId, selectedEntryId) { const sel = document.getElementById('milestoneEntry'); sel.innerHTML = ''; const projEntries = ganttData.entries.filter(e => e.project_id === parseInt(projectId)); projEntries.forEach(e => { const opt = document.createElement('option'); opt.value = e.entry_id; opt.textContent = `${e.phase_name} > ${e.task_name}`; if (selectedEntryId && e.entry_id === selectedEntryId) opt.selected = true; sel.appendChild(opt); }); } function closeMilestoneModal() { document.getElementById('milestoneModal').classList.add('hidden'); } async function saveMilestone() { const milestoneId = document.getElementById('milestoneId').value; const data = { project_id: document.getElementById('milestoneProject').value, milestone_name: document.getElementById('milestoneName').value.trim(), milestone_date: document.getElementById('milestoneDate').value, milestone_type: document.getElementById('milestoneType').value, status: document.getElementById('milestoneStatus').value, entry_id: document.getElementById('milestoneEntry').value || null, notes: document.getElementById('milestoneNotes').value || null }; if (!data.milestone_name || !data.milestone_date) { showToast('마일스톤명과 날짜를 입력해주세요.', 'error'); return; } try { if (milestoneId) { await api(`/schedule/milestones/${milestoneId}`, { method: 'PUT', body: JSON.stringify(data) }); showToast('마일스톤이 수정되었습니다.'); } else { await api('/schedule/milestones', { method: 'POST', body: JSON.stringify(data) }); showToast('마일스톤이 추가되었습니다.'); } closeMilestoneModal(); await loadGantt(); } catch (err) { showToast(err.message, 'error'); } } /* ===== Utility ===== */ function populateSelect(selectId, items, valueField, labelFn) { const sel = document.getElementById(selectId); const oldVal = sel.value; sel.innerHTML = items.map(item => ``).join(''); if (oldVal && sel.querySelector(`option[value="${oldVal}"]`)) sel.value = oldVal; } function loadTemplateOptions(selectId, phaseId) { const sel = document.getElementById(selectId); sel.innerHTML = ''; templates.filter(t => t.phase_id === parseInt(phaseId)).forEach(t => { sel.innerHTML += ``; }); }