Files
tk-factory-services/system1-factory/web/js/schedule.js
Hyungi Ahn 184cdd6aa8 fix(tkfb): project_code → job_no 컬럼명 수정 (500 에러 해결)
projects 테이블에 project_code 컬럼이 없고 job_no가 올바른 컬럼명.
백엔드 SQL에서는 pr.job_no AS project_code alias 사용,
프론트 드롭다운에서는 p.job_no로 직접 참조.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-17 08:18:49 +09:00

700 lines
34 KiB
JavaScript

/* 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 = '<div class="gantt-month-header"><div class="gantt-label"><div class="label-content font-semibold text-sm text-gray-600">프로젝트 / 단계 / 작업</div></div><div class="gantt-timeline">';
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 += `<div class="gantt-day month-label" style="flex: 0 0 ${monthWidth}px;">${monthNames[m]}</div>`;
}
headerHtml += '</div></div>';
// Virtual scroll container
const totalHeight = allRows.length * ROW_HEIGHT;
let rowsHtml = `<div style="height:${totalHeight}px;position:relative;" id="ganttVirtualBody"></div>`;
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 `<div class="gantt-row project-row" style="${style}">
<div class="gantt-label"><div class="label-content collapse-toggle ${arrowClass}" onclick="toggleProject('${row.code}')">
<span class="arrow">▼</span>${escapeHtml(row.label)}
</div></div>
<div class="gantt-timeline" style="width:${totalDays * dayWidth}px;position:relative;"></div>
</div>`;
}
if (row.type === 'phase') {
return `<div class="gantt-row phase-row" style="${style}">
<div class="gantt-label"><div class="label-content"><span style="display:inline-block;width:10px;height:10px;border-radius:2px;background:${row.color};margin-right:6px;"></span>${escapeHtml(row.label)}</div></div>
<div class="gantt-timeline" style="width:${totalDays * dayWidth}px;"></div>
</div>`;
}
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 ? `<div class="gantt-bar" style="left:${bar.left}px;width:${bar.width}px;background:${row.color};opacity:${opacity};"
onclick="showBarDetail(${e.entry_id})" title="${escapeHtml(e.task_name)}&#10;${formatDate(e.start_date)}~${formatDate(e.end_date)}&#10;진행률: ${e.progress}%">
<div class="gantt-bar-progress" style="width:${e.progress}%;background:#fff;"></div>
${bar.width > 50 ? `<div class="gantt-bar-label">${escapeHtml(e.task_name)}</div>` : ''}
</div>` : '';
return `<div class="gantt-row task-row" style="${style}">
<div class="gantt-label"><div class="label-content" title="${escapeHtml(e.task_name)}">${escapeHtml(e.task_name)}${e.assignee ? ` <span class="text-gray-400 text-xs">(${escapeHtml(e.assignee)})</span>` : ''}</div></div>
<div class="gantt-timeline" style="width:${totalDays * dayWidth}px;position:relative;">${barHtml}</div>
</div>`;
}
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 += `<div class="milestone-marker" style="left:${pos - 7}px;background:${mColor};" title="${escapeHtml(m.milestone_name)}&#10;${formatDate(m.milestone_date)}" onclick="showMilestoneDetail(${m.milestone_id})"></div>`;
}
}
return `<div class="gantt-row milestone-row" style="${style}">
<div class="gantt-label"><div class="label-content">${escapeHtml(row.label)}</div></div>
<div class="gantt-timeline" style="width:${totalDays * dayWidth}px;position:relative;">${markers}</div>
</div>`;
}
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 += `<div class="nc-badge" style="left:${pos - 10}px;" onclick="showNcPopup(${row.project_id})">${cnt}</div>`;
}
}
return `<div class="gantt-row nc-row" style="${style}">
<div class="gantt-label"><div class="label-content" style="cursor:pointer;" onclick="showNcPopup(${row.project_id})">${escapeHtml(row.label)}</div></div>
<div class="gantt-timeline" style="width:${totalDays * dayWidth}px;position:relative;">${badges}</div>
</div>`;
}
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 = `
<div class="space-y-2 text-sm">
<div class="flex justify-between"><span class="text-gray-500">프로젝트</span><span>${escapeHtml(entry.project_code)} ${escapeHtml(entry.project_name)}</span></div>
<div class="flex justify-between"><span class="text-gray-500">공정 단계</span><span>${escapeHtml(entry.phase_name)}</span></div>
<div class="flex justify-between"><span class="text-gray-500">기간</span><span>${formatDate(entry.start_date)} ~ ${formatDate(entry.end_date)}</span></div>
<div class="flex justify-between"><span class="text-gray-500">진행률</span><span>${entry.progress}%</span></div>
<div class="flex justify-between"><span class="text-gray-500">상태</span><span>${statusLabels[entry.status] || entry.status}</span></div>
${entry.assignee ? `<div class="flex justify-between"><span class="text-gray-500">담당자</span><span>${escapeHtml(entry.assignee)}</span></div>` : ''}
${entry.notes ? `<div><span class="text-gray-500">메모:</span> ${escapeHtml(entry.notes)}</div>` : ''}
</div>
`;
let actions = '';
if (canEdit) {
actions = `<button onclick="document.getElementById('barDetailPopup').classList.add('hidden');openEntryModal(${entryId})" class="px-3 py-1.5 text-sm bg-orange-600 text-white rounded-lg hover:bg-orange-700">수정</button>`;
}
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 = `
<div class="space-y-2 text-sm">
<div class="flex justify-between"><span class="text-gray-500">프로젝트</span><span>${escapeHtml(m.project_code)} ${escapeHtml(m.project_name)}</span></div>
<div class="flex justify-between"><span class="text-gray-500">날짜</span><span>${formatDate(m.milestone_date)}</span></div>
<div class="flex justify-between"><span class="text-gray-500">유형</span><span>${typeLabels[m.milestone_type] || m.milestone_type}</span></div>
<div class="flex justify-between"><span class="text-gray-500">상태</span><span>${statusLabels[m.status] || m.status}</span></div>
${m.notes ? `<div><span class="text-gray-500">메모:</span> ${escapeHtml(m.notes)}</div>` : ''}
</div>
`;
let actions = '';
if (canEdit) {
actions = `<button onclick="document.getElementById('barDetailPopup').classList.add('hidden');openMilestoneModal(${milestoneId})" class="px-3 py-1.5 text-sm bg-purple-600 text-white rounded-lg hover:bg-purple-700">수정</button>`;
}
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 = '<p class="text-gray-500 text-sm">부적합 내역이 없습니다.</p>';
} else {
html = '<table class="data-table"><thead><tr><th>일자</th><th>분류</th><th>내용</th><th>상태</th></tr></thead><tbody>';
for (const nc of list) {
html += `<tr>
<td>${formatDate(nc.report_date)}</td>
<td>${escapeHtml(nc.category || '-')}</td>
<td class="max-w-[200px] truncate">${escapeHtml(nc.description || '-')}</td>
<td><span class="badge ${nc.review_status === 'completed' ? 'badge-green' : 'badge-amber'}">${statusLabels[nc.review_status] || nc.review_status}</span></td>
</tr>`;
}
html += '</tbody></table>';
}
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 = '<p class="text-gray-500 text-sm">해당 단계에 템플릿이 없습니다.</p>'; return; }
list.innerHTML = filtered.map((t, i) => `
<div class="flex items-center gap-3 p-2 bg-gray-50 rounded-lg">
<input type="checkbox" id="btmpl_${t.template_id}" checked class="w-4 h-4">
<span class="flex-1 text-sm">${escapeHtml(t.task_name)}</span>
<span class="text-xs text-gray-400">${t.default_duration_days}일</span>
<input type="date" id="btmpl_start_${t.template_id}" class="input-field rounded px-2 py-1 text-xs w-32">
<span class="text-xs text-gray-400">~</span>
<input type="date" id="btmpl_end_${t.template_id}" class="input-field rounded px-2 py-1 text-xs w-32">
</div>
`).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 = '<option value="">없음</option>';
}
// 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 = '<option value="">없음</option>';
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 => `<option value="${item[valueField]}">${escapeHtml(labelFn(item))}</option>`).join('');
if (oldVal && sel.querySelector(`option[value="${oldVal}"]`)) sel.value = oldVal;
}
function loadTemplateOptions(selectId, phaseId) {
const sel = document.getElementById(selectId);
sel.innerHTML = '<option value="">직접 입력</option>';
templates.filter(t => t.phase_id === parseInt(phaseId)).forEach(t => {
sel.innerHTML += `<option value="${t.template_id}">${escapeHtml(t.task_name)} (${t.default_duration_days}일)</option>`;
});
}