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>
700 lines
34 KiB
JavaScript
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)} ${formatDate(e.start_date)}~${formatDate(e.end_date)} 진행률: ${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)} ${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>`;
|
|
});
|
|
}
|