feat(tkfb): 공정표 + 생산회의록 시스템 추가
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
358
system1-factory/web/js/meeting-detail.js
Normal file
358
system1-factory/web/js/meeting-detail.js
Normal file
@@ -0,0 +1,358 @@
|
||||
/* meeting-detail.js — 회의록 상세/작성 */
|
||||
|
||||
let meetingId = null;
|
||||
let meetingData = null;
|
||||
let selectedAttendees = []; // [{user_id, name, username}]
|
||||
let projects = [];
|
||||
let users = [];
|
||||
let canEdit = false;
|
||||
let isAdmin = false;
|
||||
let isPublished = false;
|
||||
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
const ok = await initAuth();
|
||||
if (!ok) return;
|
||||
document.querySelector('.fade-in').classList.add('visible');
|
||||
|
||||
const role = currentUser?.role || '';
|
||||
canEdit = ['support_team', 'admin', 'system', 'system admin'].includes(role);
|
||||
isAdmin = ['admin', 'system', 'system admin'].includes(role);
|
||||
|
||||
// Parse URL
|
||||
const params = new URLSearchParams(location.search);
|
||||
meetingId = params.get('id');
|
||||
|
||||
// Load master data
|
||||
try {
|
||||
const [projRes, userRes] = await Promise.all([
|
||||
api('/projects'),
|
||||
api('/users')
|
||||
]);
|
||||
projects = projRes.data || [];
|
||||
users = (userRes.data || []).filter(u => u.is_active !== 0);
|
||||
} catch {}
|
||||
|
||||
// Populate project select in item modal
|
||||
const projSel = document.getElementById('itemProject');
|
||||
projects.forEach(p => {
|
||||
projSel.innerHTML += `<option value="${p.project_id}">${escapeHtml(p.project_code)} ${escapeHtml(p.project_name)}</option>`;
|
||||
});
|
||||
|
||||
// Populate responsible user select
|
||||
const respSel = document.getElementById('itemResponsible');
|
||||
users.forEach(u => {
|
||||
respSel.innerHTML += `<option value="${u.user_id}">${escapeHtml(u.name)} (${escapeHtml(u.username)})</option>`;
|
||||
});
|
||||
|
||||
// Attendee search
|
||||
const searchInput = document.getElementById('attendeeSearch');
|
||||
const resultsDiv = document.getElementById('attendeeResults');
|
||||
searchInput.addEventListener('input', debounce(() => {
|
||||
const q = searchInput.value.trim().toLowerCase();
|
||||
if (q.length < 1) { resultsDiv.classList.add('hidden'); return; }
|
||||
const matches = users.filter(u =>
|
||||
!selectedAttendees.some(a => a.user_id === u.user_id) &&
|
||||
(u.name?.toLowerCase().includes(q) || u.username?.toLowerCase().includes(q))
|
||||
).slice(0, 10);
|
||||
if (matches.length === 0) { resultsDiv.classList.add('hidden'); return; }
|
||||
resultsDiv.innerHTML = matches.map(u =>
|
||||
`<div class="user-search-item" onclick="addAttendee(${u.user_id}, '${escapeHtml(u.name)}', '${escapeHtml(u.username)}')">${escapeHtml(u.name)} <span class="text-gray-400">(${escapeHtml(u.username)})</span></div>`
|
||||
).join('');
|
||||
resultsDiv.classList.remove('hidden');
|
||||
}, 200));
|
||||
searchInput.addEventListener('blur', () => setTimeout(() => resultsDiv.classList.add('hidden'), 200));
|
||||
|
||||
if (meetingId) {
|
||||
await loadMeeting();
|
||||
} else {
|
||||
// New meeting
|
||||
document.getElementById('meetingDate').value = new Date().toISOString().split('T')[0];
|
||||
updateUI();
|
||||
}
|
||||
});
|
||||
|
||||
async function loadMeeting() {
|
||||
try {
|
||||
const res = await api(`/meetings/${meetingId}`);
|
||||
meetingData = res.data;
|
||||
isPublished = meetingData.status === 'published';
|
||||
|
||||
document.getElementById('pageTitle').textContent = meetingData.title;
|
||||
document.getElementById('meetingDate').value = formatDate(meetingData.meeting_date);
|
||||
document.getElementById('meetingTime').value = meetingData.meeting_time || '';
|
||||
document.getElementById('meetingTitle').value = meetingData.title;
|
||||
document.getElementById('meetingLocation').value = meetingData.location || '';
|
||||
document.getElementById('meetingSummary').value = meetingData.summary || '';
|
||||
|
||||
// Status badge
|
||||
const badge = document.getElementById('statusBadge');
|
||||
badge.classList.remove('hidden');
|
||||
if (isPublished) {
|
||||
badge.className = 'badge badge-green';
|
||||
badge.textContent = '발행';
|
||||
} else {
|
||||
badge.className = 'badge badge-gray';
|
||||
badge.textContent = '초안';
|
||||
}
|
||||
|
||||
// Attendees
|
||||
selectedAttendees = (meetingData.attendees || []).map(a => ({
|
||||
user_id: a.user_id, name: a.name, username: a.username
|
||||
}));
|
||||
renderAttendees();
|
||||
|
||||
// Agenda items
|
||||
renderAgendaItems(meetingData.items || []);
|
||||
updateUI();
|
||||
} catch (err) {
|
||||
showToast('회의록 로드 실패: ' + err.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function updateUI() {
|
||||
const editable = canEdit && (!isPublished || isAdmin);
|
||||
// Fields
|
||||
['meetingDate', 'meetingTime', 'meetingTitle', 'meetingLocation', 'meetingSummary', 'attendeeSearch'].forEach(id => {
|
||||
const el = document.getElementById(id);
|
||||
if (el) { el.disabled = !editable; if (!editable) el.classList.add('bg-gray-100'); }
|
||||
});
|
||||
|
||||
// Buttons
|
||||
document.getElementById('btnSave').classList.toggle('hidden', !editable);
|
||||
document.getElementById('btnAddItem').classList.toggle('hidden', !editable);
|
||||
document.getElementById('btnPublish').classList.toggle('hidden', !canEdit || isPublished || !meetingId);
|
||||
document.getElementById('btnUnpublish').classList.toggle('hidden', !isAdmin || !isPublished);
|
||||
document.getElementById('btnDelete').classList.toggle('hidden', !isAdmin || !meetingId);
|
||||
}
|
||||
|
||||
/* ===== Attendees ===== */
|
||||
function addAttendee(userId, name, username) {
|
||||
if (selectedAttendees.some(a => a.user_id === userId)) return;
|
||||
selectedAttendees.push({ user_id: userId, name, username });
|
||||
renderAttendees();
|
||||
document.getElementById('attendeeSearch').value = '';
|
||||
document.getElementById('attendeeResults').classList.add('hidden');
|
||||
}
|
||||
|
||||
function removeAttendee(userId) {
|
||||
selectedAttendees = selectedAttendees.filter(a => a.user_id !== userId);
|
||||
renderAttendees();
|
||||
}
|
||||
|
||||
function renderAttendees() {
|
||||
const container = document.getElementById('attendeeTags');
|
||||
const editable = canEdit && (!isPublished || isAdmin);
|
||||
container.innerHTML = selectedAttendees.map(a =>
|
||||
`<span class="attendee-tag">${escapeHtml(a.name)}${editable ? ` <span class="remove-btn" onclick="removeAttendee(${a.user_id})">×</span>` : ''}</span>`
|
||||
).join('');
|
||||
}
|
||||
|
||||
/* ===== Agenda Items ===== */
|
||||
function renderAgendaItems(items) {
|
||||
const list = document.getElementById('agendaList');
|
||||
const empty = document.getElementById('agendaEmpty');
|
||||
|
||||
if (items.length === 0) {
|
||||
list.innerHTML = '';
|
||||
empty.classList.remove('hidden');
|
||||
return;
|
||||
}
|
||||
empty.classList.add('hidden');
|
||||
|
||||
const typeLabels = { schedule_update: '공정현황', issue: '이슈', decision: '결정사항', action_item: '조치사항', other: '기타' };
|
||||
const typeColors = { schedule_update: 'badge-blue', issue: 'badge-red', decision: 'badge-green', action_item: 'badge-amber', other: 'badge-gray' };
|
||||
const statusLabels = { open: '미처리', in_progress: '진행중', completed: '완료', cancelled: '취소' };
|
||||
const statusColors = { open: 'badge-amber', in_progress: 'badge-blue', completed: 'badge-green', cancelled: 'badge-gray' };
|
||||
|
||||
const editable = canEdit && (!isPublished || isAdmin);
|
||||
const canUpdateStatus = ['group_leader', 'support_team', 'admin', 'system', 'system admin'].includes(currentUser?.role || '');
|
||||
|
||||
list.innerHTML = items.map(item => `
|
||||
<div class="border rounded-lg p-4">
|
||||
<div class="flex items-start justify-between gap-2 mb-2">
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<span class="badge ${typeColors[item.item_type] || 'badge-gray'}">${typeLabels[item.item_type] || item.item_type}</span>
|
||||
<span class="badge ${statusColors[item.status] || 'badge-gray'}">${statusLabels[item.status] || item.status}</span>
|
||||
${item.project_code ? `<span class="text-xs text-gray-400">${escapeHtml(item.project_code)}</span>` : ''}
|
||||
${item.milestone_name ? `<span class="text-xs text-purple-500">◆ ${escapeHtml(item.milestone_name)}</span>` : ''}
|
||||
</div>
|
||||
<div class="flex items-center gap-1 flex-shrink-0">
|
||||
${canUpdateStatus && item.status !== 'completed' ? `<select class="text-xs border rounded px-1 py-0.5" onchange="updateItemStatus(${item.item_id}, this.value)">
|
||||
<option value="">상태변경</option>
|
||||
<option value="in_progress">진행중</option>
|
||||
<option value="completed">완료</option>
|
||||
</select>` : ''}
|
||||
${editable ? `<button onclick="openItemModal(${item.item_id})" class="text-gray-400 hover:text-orange-600 text-xs px-1"><i class="fas fa-edit"></i></button>
|
||||
<button onclick="deleteItem(${item.item_id})" class="text-gray-400 hover:text-red-600 text-xs px-1"><i class="fas fa-trash"></i></button>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-sm text-gray-800 mb-1">${escapeHtml(item.content)}</p>
|
||||
${item.decision ? `<p class="text-sm text-green-700 bg-green-50 rounded p-2 mb-1"><strong>결정:</strong> ${escapeHtml(item.decision)}</p>` : ''}
|
||||
${item.action_required ? `<p class="text-sm text-amber-700 bg-amber-50 rounded p-2 mb-1"><strong>조치:</strong> ${escapeHtml(item.action_required)}</p>` : ''}
|
||||
<div class="flex items-center gap-4 text-xs text-gray-400 mt-2">
|
||||
${item.responsible_name ? `<span><i class="fas fa-user mr-1"></i>${escapeHtml(item.responsible_name)}</span>` : ''}
|
||||
${item.due_date ? `<span class="${new Date(item.due_date) < new Date() && item.status !== 'completed' ? 'text-red-500 font-semibold' : ''}"><i class="fas fa-clock mr-1"></i>${formatDate(item.due_date)}</span>` : ''}
|
||||
${item.milestone_name ? `<a href="/pages/work/schedule.html?highlight=${item.milestone_id}" class="text-purple-500 hover:text-purple-700"><i class="fas fa-calendar-alt mr-1"></i>공정표 보기</a>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
/* ===== Save Meeting ===== */
|
||||
async function saveMeeting() {
|
||||
const title = document.getElementById('meetingTitle').value.trim();
|
||||
const meetingDate = document.getElementById('meetingDate').value;
|
||||
if (!title || !meetingDate) { showToast('날짜와 제목은 필수입니다.', 'error'); return; }
|
||||
|
||||
const data = {
|
||||
meeting_date: meetingDate,
|
||||
meeting_time: document.getElementById('meetingTime').value || null,
|
||||
title,
|
||||
location: document.getElementById('meetingLocation').value || null,
|
||||
summary: document.getElementById('meetingSummary').value || null,
|
||||
attendees: selectedAttendees.map(a => a.user_id)
|
||||
};
|
||||
|
||||
try {
|
||||
if (meetingId) {
|
||||
await api(`/meetings/${meetingId}`, { method: 'PUT', body: JSON.stringify(data) });
|
||||
showToast('회의록이 저장되었습니다.');
|
||||
} else {
|
||||
const res = await api('/meetings', { method: 'POST', body: JSON.stringify(data) });
|
||||
meetingId = res.data.meeting_id;
|
||||
history.replaceState(null, '', `?id=${meetingId}`);
|
||||
showToast('회의록이 생성되었습니다.');
|
||||
}
|
||||
await loadMeeting();
|
||||
} catch (err) { showToast(err.message, 'error'); }
|
||||
}
|
||||
|
||||
async function publishMeeting() {
|
||||
if (!confirm('회의록을 발행하시겠습니까? 발행 후 일반 사용자는 수정할 수 없습니다.')) return;
|
||||
try {
|
||||
await api(`/meetings/${meetingId}/publish`, { method: 'PUT' });
|
||||
showToast('회의록이 발행되었습니다.');
|
||||
await loadMeeting();
|
||||
} catch (err) { showToast(err.message, 'error'); }
|
||||
}
|
||||
|
||||
async function unpublishMeeting() {
|
||||
if (!confirm('발행을 취소하시겠습니까?')) return;
|
||||
try {
|
||||
await api(`/meetings/${meetingId}/unpublish`, { method: 'PUT' });
|
||||
showToast('발행이 취소되었습니다.');
|
||||
await loadMeeting();
|
||||
} catch (err) { showToast(err.message, 'error'); }
|
||||
}
|
||||
|
||||
async function deleteMeeting() {
|
||||
if (!confirm('회의록을 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.')) return;
|
||||
try {
|
||||
await api(`/meetings/${meetingId}`, { method: 'DELETE' });
|
||||
showToast('회의록이 삭제되었습니다.');
|
||||
location.href = '/pages/work/meetings.html';
|
||||
} catch (err) { showToast(err.message, 'error'); }
|
||||
}
|
||||
|
||||
/* ===== Agenda Item Modal ===== */
|
||||
function openItemModal(editItemId) {
|
||||
const modal = document.getElementById('itemModal');
|
||||
const isEdit = !!editItemId;
|
||||
document.getElementById('itemModalTitle').textContent = isEdit ? '안건 수정' : '안건 추가';
|
||||
|
||||
if (isEdit && meetingData) {
|
||||
const item = meetingData.items.find(i => i.item_id === editItemId);
|
||||
if (!item) return;
|
||||
document.getElementById('itemId').value = editItemId;
|
||||
document.getElementById('itemType').value = item.item_type;
|
||||
document.getElementById('itemProject').value = item.project_id || '';
|
||||
loadItemMilestones(item.milestone_id);
|
||||
document.getElementById('itemContent').value = item.content;
|
||||
document.getElementById('itemDecision').value = item.decision || '';
|
||||
document.getElementById('itemAction').value = item.action_required || '';
|
||||
document.getElementById('itemResponsible').value = item.responsible_user_id || '';
|
||||
document.getElementById('itemDueDate').value = item.due_date ? formatDate(item.due_date) : '';
|
||||
document.getElementById('itemStatus').value = item.status;
|
||||
} else {
|
||||
document.getElementById('itemId').value = '';
|
||||
document.getElementById('itemType').value = 'schedule_update';
|
||||
document.getElementById('itemProject').value = '';
|
||||
document.getElementById('itemMilestone').innerHTML = '<option value="">선택안함</option>';
|
||||
document.getElementById('itemContent').value = '';
|
||||
document.getElementById('itemDecision').value = '';
|
||||
document.getElementById('itemAction').value = '';
|
||||
document.getElementById('itemResponsible').value = '';
|
||||
document.getElementById('itemDueDate').value = '';
|
||||
document.getElementById('itemStatus').value = 'open';
|
||||
}
|
||||
|
||||
modal.classList.remove('hidden');
|
||||
}
|
||||
|
||||
function closeItemModal() { document.getElementById('itemModal').classList.add('hidden'); }
|
||||
|
||||
async function loadItemMilestones(selectedId) {
|
||||
const projectId = document.getElementById('itemProject').value;
|
||||
const sel = document.getElementById('itemMilestone');
|
||||
sel.innerHTML = '<option value="">선택안함</option>';
|
||||
if (!projectId) return;
|
||||
try {
|
||||
const res = await api(`/schedule/milestones?project_id=${projectId}`);
|
||||
(res.data || []).forEach(m => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = m.milestone_id;
|
||||
opt.textContent = `${m.milestone_name} (${formatDate(m.milestone_date)})`;
|
||||
if (selectedId && m.milestone_id === selectedId) opt.selected = true;
|
||||
sel.appendChild(opt);
|
||||
});
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async function saveItem() {
|
||||
const content = document.getElementById('itemContent').value.trim();
|
||||
if (!content) { showToast('안건 내용을 입력해주세요.', 'error'); return; }
|
||||
if (!meetingId) { showToast('회의록을 먼저 저장해주세요.', 'error'); return; }
|
||||
|
||||
const itemId = document.getElementById('itemId').value;
|
||||
const data = {
|
||||
item_type: document.getElementById('itemType').value,
|
||||
project_id: document.getElementById('itemProject').value || null,
|
||||
milestone_id: document.getElementById('itemMilestone').value || null,
|
||||
content,
|
||||
decision: document.getElementById('itemDecision').value || null,
|
||||
action_required: document.getElementById('itemAction').value || null,
|
||||
responsible_user_id: document.getElementById('itemResponsible').value || null,
|
||||
due_date: document.getElementById('itemDueDate').value || null,
|
||||
status: document.getElementById('itemStatus').value
|
||||
};
|
||||
|
||||
try {
|
||||
if (itemId) {
|
||||
await api(`/meetings/${meetingId}/items/${itemId}`, { method: 'PUT', body: JSON.stringify(data) });
|
||||
showToast('안건이 수정되었습니다.');
|
||||
} else {
|
||||
await api(`/meetings/${meetingId}/items`, { method: 'POST', body: JSON.stringify(data) });
|
||||
showToast('안건이 추가되었습니다.');
|
||||
}
|
||||
closeItemModal();
|
||||
await loadMeeting();
|
||||
} catch (err) { showToast(err.message, 'error'); }
|
||||
}
|
||||
|
||||
async function deleteItem(itemId) {
|
||||
if (!confirm('안건을 삭제하시겠습니까?')) return;
|
||||
try {
|
||||
await api(`/meetings/${meetingId}/items/${itemId}`, { method: 'DELETE' });
|
||||
showToast('안건이 삭제되었습니다.');
|
||||
await loadMeeting();
|
||||
} catch (err) { showToast(err.message, 'error'); }
|
||||
}
|
||||
|
||||
async function updateItemStatus(itemId, status) {
|
||||
if (!status) return;
|
||||
try {
|
||||
await api(`/meetings/items/${itemId}/status`, { method: 'PUT', body: JSON.stringify({ status }) });
|
||||
showToast('상태가 업데이트되었습니다.');
|
||||
await loadMeeting();
|
||||
} catch (err) { showToast(err.message, 'error'); }
|
||||
}
|
||||
106
system1-factory/web/js/meetings.js
Normal file
106
system1-factory/web/js/meetings.js
Normal file
@@ -0,0 +1,106 @@
|
||||
/* meetings.js — 생산회의록 목록 */
|
||||
|
||||
let canEdit = false;
|
||||
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
const ok = await initAuth();
|
||||
if (!ok) return;
|
||||
document.querySelector('.fade-in').classList.add('visible');
|
||||
|
||||
const role = currentUser?.role || '';
|
||||
canEdit = ['support_team', 'admin', 'system', 'system admin'].includes(role);
|
||||
if (canEdit) document.getElementById('btnNewMeeting').classList.remove('hidden');
|
||||
|
||||
// Year filter
|
||||
const yearSel = document.getElementById('yearFilter');
|
||||
const now = new Date();
|
||||
for (let y = now.getFullYear() - 2; y <= now.getFullYear() + 1; y++) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = y; opt.textContent = y + '년';
|
||||
if (y === now.getFullYear()) opt.selected = true;
|
||||
yearSel.appendChild(opt);
|
||||
}
|
||||
document.getElementById('monthFilter').value = String(now.getMonth() + 1);
|
||||
|
||||
yearSel.addEventListener('change', loadMeetings);
|
||||
document.getElementById('monthFilter').addEventListener('change', loadMeetings);
|
||||
document.getElementById('searchInput').addEventListener('input', debounce(loadMeetings, 300));
|
||||
document.getElementById('btnNewMeeting').addEventListener('click', () => {
|
||||
location.href = '/pages/work/meeting-detail.html';
|
||||
});
|
||||
|
||||
await Promise.all([loadMeetings(), loadActionItems()]);
|
||||
});
|
||||
|
||||
async function loadMeetings() {
|
||||
try {
|
||||
const year = document.getElementById('yearFilter').value;
|
||||
const month = document.getElementById('monthFilter').value;
|
||||
const search = document.getElementById('searchInput').value.trim();
|
||||
let url = `/meetings?year=${year}`;
|
||||
if (month) url += `&month=${month}`;
|
||||
if (search) url += `&search=${encodeURIComponent(search)}`;
|
||||
const res = await api(url);
|
||||
renderMeetings(res.data || []);
|
||||
} catch (err) {
|
||||
showToast('회의록 목록 로드 실패: ' + err.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function renderMeetings(meetings) {
|
||||
const list = document.getElementById('meetingList');
|
||||
const empty = document.getElementById('emptyState');
|
||||
|
||||
if (meetings.length === 0) {
|
||||
list.innerHTML = '';
|
||||
empty.classList.remove('hidden');
|
||||
return;
|
||||
}
|
||||
empty.classList.add('hidden');
|
||||
|
||||
list.innerHTML = meetings.map(m => {
|
||||
const statusBadge = m.status === 'published'
|
||||
? '<span class="badge badge-green">발행</span>'
|
||||
: '<span class="badge badge-gray">초안</span>';
|
||||
return `
|
||||
<a href="/pages/work/meeting-detail.html?id=${m.meeting_id}" class="block bg-white rounded-xl shadow-sm p-4 hover:shadow-md transition-shadow">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<span class="text-sm text-gray-500">${formatDate(m.meeting_date)}</span>
|
||||
${statusBadge}
|
||||
</div>
|
||||
<h3 class="font-semibold text-gray-800 truncate">${escapeHtml(m.title)}</h3>
|
||||
<div class="flex items-center gap-4 mt-2 text-xs text-gray-500">
|
||||
<span><i class="fas fa-user mr-1"></i>${escapeHtml(m.created_by_name || '-')}</span>
|
||||
<span><i class="fas fa-users mr-1"></i>참석 ${m.attendee_count || 0}명</span>
|
||||
<span><i class="fas fa-list mr-1"></i>안건 ${m.agenda_count || 0}건</span>
|
||||
${m.open_action_count > 0 ? `<span class="text-amber-600 font-semibold"><i class="fas fa-exclamation-circle mr-1"></i>미완료 ${m.open_action_count}건</span>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<i class="fas fa-chevron-right text-gray-300 mt-2"></i>
|
||||
</div>
|
||||
</a>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
async function loadActionItems() {
|
||||
try {
|
||||
const res = await api('/meetings/action-items?status=open');
|
||||
const items = res.data || [];
|
||||
if (items.length === 0) return;
|
||||
|
||||
document.getElementById('actionSummary').classList.remove('hidden');
|
||||
document.getElementById('actionCount').textContent = items.length;
|
||||
|
||||
document.getElementById('actionList').innerHTML = items.slice(0, 5).map(item => `
|
||||
<div class="flex items-center gap-2 p-1.5 bg-white rounded">
|
||||
<span class="text-amber-600"><i class="fas fa-circle text-[6px]"></i></span>
|
||||
<span class="flex-1 truncate">${escapeHtml(item.content)}</span>
|
||||
${item.responsible_name ? `<span class="text-gray-400 text-xs">${escapeHtml(item.responsible_name)}</span>` : ''}
|
||||
${item.due_date ? `<span class="text-xs ${new Date(item.due_date) < new Date() ? 'text-red-500 font-semibold' : 'text-gray-400'}">${formatDate(item.due_date)}</span>` : ''}
|
||||
</div>
|
||||
`).join('') + (items.length > 5 ? `<div class="text-xs text-gray-400 text-center mt-1">외 ${items.length - 5}건</div>` : '');
|
||||
} catch {}
|
||||
}
|
||||
699
system1-factory/web/js/schedule.js
Normal file
699
system1-factory/web/js/schedule.js
Normal file
@@ -0,0 +1,699 @@
|
||||
/* 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.project_code : ''}`;
|
||||
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.project_code} ${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.project_code} ${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.project_code} ${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>`;
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user