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

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

359 lines
17 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* 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'); }
}