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>
359 lines
17 KiB
JavaScript
359 lines
17 KiB
JavaScript
/* 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.job_no)} ${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'); }
|
||
}
|