let members = []; let isAdmin = false; let selectedDept = '전체'; let editingMembers = []; let collapsedUnits = new Set(); let isListMode = false; let isListDetailModal = false; let emptyStateMessage = '서버에 조직 데이터가 없습니다. 상단의 업로드 버튼으로 초기 데이터를 넣어주세요.'; let photoPreviewObjectUrl = null; let seatMapLayoutCache = null; let activeAsOfDate = ''; let isHistoricalSnapshot = false; const listViewState = { mode: 'current', snapshotDate: '', compareFromDate: '', compareToDate: '', snapshotMembers: [], compareItems: [], }; const seatMapOfficeKeys = ['technical-development-center', 'hanmac-building-6f', 'hanmac-building-7f']; const levelOrder = ['부서', '그룹', '디비전', '팀', '셀']; const dropdownFields = ['소속회사', '직급', '직책', ...levelOrder]; function pad(value) { return String(value).padStart(2, '0'); } function updateTimestamp() { const now = new Date(); const dateStr = `${now.getFullYear()}.${pad(now.getMonth() + 1)}.${pad(now.getDate())}`; const timeStr = `${pad(now.getHours())}:${pad(now.getMinutes())}`; document.getElementById('last-updated').innerText = `WSL 서버 기준 동기화: ${dateStr} ${timeStr}`; } function rebuildMemberPath(member) { member._path = levelOrder .map((level) => ({ level, name: member[level] || '' })) .filter((item) => item.name !== ''); return member; } function cloneMembers(items) { return JSON.parse(JSON.stringify(items)); } function isRetiredLegacyMember(member) { const workStatus = String(member?.['근무상태'] || '').trim(); return workStatus === '퇴직'; } function getVisibleLegacyMembers(items) { return (items || []).filter((member) => !isRetiredLegacyMember(member)); } function getPhotoPlaceholder(name = '') { return `https://via.placeholder.com/160?text=${encodeURIComponent(name || 'Profile')}`; } function escapeHtml(value) { return String(value ?? '') .replaceAll('&', '&') .replaceAll('<', '<') .replaceAll('>', '>') .replaceAll('"', '"') .replaceAll("'", '''); } function resetPhotoPreviewObjectUrl() { if (photoPreviewObjectUrl) { URL.revokeObjectURL(photoPreviewObjectUrl); photoPreviewObjectUrl = null; } } function toLegacyMember(item) { return rebuildMemberPath({ _id: String(item.id), id: item.id, 이름: item.name || '', 사번: item.employee_id || '', 소속회사: item.company || '', 직급: item.rank || '', 직책: item.role || '', 부서: item.department || '', 그룹: item.grp || '', 디비전: item.division || '', 팀: item.team || '', 셀: item.cell || '', 근무상태: item.work_status || '', 근무시간: item.work_time || '', 전화번호: item.phone || '', 이메일: item.email || '', 자리위치: item.seat_label || '', 사진: item.photo_url || '', sort_order: item.sort_order ?? 0, }); } function toApiMember(member, sortOrder) { return { name: member['이름'] || '', employee_id: member['사번'] || '', company: member['소속회사'] || '', rank: member['직급'] || '', role: member['직책'] || '', department: member['부서'] || '', grp: member['그룹'] || '', division: member['디비전'] || '', team: member['팀'] || '', cell: member['셀'] || '', work_status: member['근무상태'] || '', work_time: member['근무시간'] || '', phone: member['전화번호'] || '', email: member['이메일'] || '', seat_label: member['자리위치'] || '', photo_url: member['사진'] || '', sort_order: sortOrder, }; } async function apiFetch(url, options = {}) { const config = { ...options }; config.headers = { ...(options.headers || {}) }; if (config.body && !(config.body instanceof FormData) && !config.headers['Content-Type']) { config.headers['Content-Type'] = 'application/json'; } const response = await fetch(url, config); const contentType = response.headers.get('content-type') || ''; const payload = contentType.includes('application/json') ? await response.json() : null; if (!response.ok) { const detail = payload?.detail || '요청 처리에 실패했습니다.'; throw new Error(detail); } return payload; } function withAsOf(url) { if (!activeAsOfDate) { return url; } const separator = url.includes('?') ? '&' : '?'; return `${url}${separator}as_of=${encodeURIComponent(activeAsOfDate)}`; } function getDefaultHistoryDate() { if (activeAsOfDate) { return activeAsOfDate; } const now = new Date(); return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`; } async function uploadProfilePhoto(file, memberName) { const formData = new FormData(); formData.append('file', file); formData.append('member_name', memberName || ''); const payload = await apiFetch('/api/uploads/profile-photo', { method: 'POST', body: formData, }); return payload.url || ''; } function setMembers(items) { members = getVisibleLegacyMembers(items.map(toLegacyMember)); if (selectedDept !== '전체' && !members.some((member) => member['부서'] === selectedDept)) { selectedDept = '전체'; } updateTimestamp(); } async function loadMembers(message) { if (message) { emptyStateMessage = message; } const payload = await apiFetch(withAsOf('/api/members')); setMembers(payload.items || []); if (!members.length) { emptyStateMessage = '서버에 조직 데이터가 없습니다. 상단의 업로드 버튼으로 초기 데이터를 넣어주세요.'; } render(); } async function loadSeatMapLayouts(force = false) { if (seatMapLayoutCache && !force) { return seatMapLayoutCache; } try { const layouts = (await Promise.all(seatMapOfficeKeys.map(async (officeKey) => { try { const activePayload = await apiFetch(`/api/seat-maps/active?office_key=${encodeURIComponent(officeKey)}`); const seatMap = activePayload?.item; if (!seatMap?.id) { return null; } return await apiFetch(withAsOf(`/api/seat-maps/${seatMap.id}/layout`)); } catch { return null; } }))).filter(Boolean); seatMapLayoutCache = layouts; return layouts; } catch { seatMapLayoutCache = null; return []; } } function handleSeatMapLayoutUpdated() { seatMapLayoutCache = null; loadMembers().catch(() => { }); } function getMemberSeatInfo(layouts, memberId) { if (!Array.isArray(layouts) || !memberId) { return null; } for (const layout of layouts) { const placement = (layout.placements || []).find((item) => Number(item.member_id) === Number(memberId)); if (!placement) { continue; } const slot = (layout.slots || []).find((item) => Number(item.id) === Number(placement.seat_slot_id)); return { layout, seatMapId: layout.seat_map?.id || null, seatMapName: layout.seat_map?.name || '자리배치도', seatLabel: placement.seat_label || slot?.label || '', slotKey: slot?.slot_key || '', assigned: true, }; } return null; } function buildSeatAssignments(layout) { if (!layout || !Array.isArray(layout.placements) || !Array.isArray(layout.members) || !Array.isArray(layout.slots)) { return []; } return layout.placements.map((placement) => { const slot = layout.slots.find((item) => Number(item.id) === Number(placement.seat_slot_id)); const memberItem = layout.members.find((item) => Number(item.id) === Number(placement.member_id)); if (!slot || !memberItem) return null; return { key: String(slot.slot_key || ''), member_id: Number(memberItem.id), name: memberItem.name || '-', rank: memberItem.rank || '-', }; }).filter(Boolean); } function applySeatPreviewFrameState(frame, seatInfo, layout) { if (!frame?.contentWindow || !seatInfo?.slotKey) { return; } const postState = () => { if (!frame.contentWindow) { return; } frame.contentWindow.postMessage({ type: 'seatmap-set-assignments', items: buildSeatAssignments(layout), }, window.location.origin); frame.contentWindow.postMessage({ type: 'seatmap-set-mode', mode: 'compact' }, window.location.origin); frame.contentWindow.postMessage({ type: 'seatmap-focus-chair', key: seatInfo.slotKey, padding: 2600 }, window.location.origin); }; postState(); setTimeout(postState, 120); } async function syncMembers(nextMembers) { const payload = await apiFetch('/api/members/bulk-sync', { method: 'PUT', body: JSON.stringify({ items: nextMembers.map((member, index) => toApiMember(member, index)), }), }); setMembers(payload.items || []); render(); } function jsString(value) { return String(value).replaceAll('\\', '\\\\').replaceAll("'", "\\'"); } function updateDeptTabs(deptList) { const tabsContainer = document.getElementById('dept-tabs'); tabsContainer.innerHTML = deptList.map((dept) => `
${dept}
`).join(''); } function selectDept(dept) { selectedDept = dept; render(); } function calculateTotalCount(node) { let count = node.members.length; if (node.children) { node.children.forEach((child) => { count += calculateTotalCount(child); }); } node.totalCount = count; return count; } function buildHierarchy(data, depth) { if (!data || data.length === 0) { return []; } const orderedGroups = []; const groupMap = {}; data.forEach((member) => { const currentStep = member._path[depth]; if (!currentStep) { return; } const currentName = currentStep.name; if (!groupMap[currentName]) { groupMap[currentName] = { name: currentName, level: currentStep.level, members: [], subData: [], }; orderedGroups.push(groupMap[currentName]); } if (member._path.length === depth + 1) { groupMap[currentName].members.push(member); } else { groupMap[currentName].subData.push(member); } }); return orderedGroups.map((group) => ({ ...group, children: buildHierarchy(group.subData, depth + 1), })); } function createMemberCard(member, isFullWidth = false) { const card = document.createElement('div'); card.id = `card-${member._id}`; card.className = `member-card co-${member['소속회사'] || 'default'} transition-all duration-200 mb-1 last:mb-0`; if (isFullWidth) { card.classList.add('full-width'); } card.onclick = (event) => { event.stopPropagation(); openModal(member._id); }; if (isAdmin) { card.setAttribute('draggable', 'true'); card.ondragstart = (event) => handleDragStart(event, 'member', member._id); card.ondragend = (event) => handleDragEnd(event); card.ondragover = (event) => handleDragOverMember(event); card.ondragleave = (event) => handleDragLeaveMember(event); card.ondrop = (event) => handleDropMember(event, member._id); } const isLeave = member['근무상태'] === '휴직'; const roleDisplay = isLeave ? '휴직' : ((member['직책'] && member['직책'] !== '팀원') ? `${member['직책']}` : ''); card.innerHTML = `
${member['이름']}${roleDisplay}${member['직급'] || ''}
`; return card; } function getAllSubMembers(node) { let memberList = [...node.members]; node.children.forEach((child) => { memberList = memberList.concat(getAllSubMembers(child)); }); return memberList; } function collectTeamItems(teamNode) { let items = [...teamNode.members]; teamNode.children.forEach((cell) => { items.push({ isCellHeader: true, name: cell.name }); items = items.concat(getAllSubMembers(cell)); }); return items; } function createNodeDOM(node, parentId) { const nodeItem = document.createElement('div'); nodeItem.className = `node-item${node.children.length || node.members.length ? ' has-children' : ''}`; const myId = `node-${encodeURIComponent(`${node.level}_${node.name}`)}`; const box = document.createElement('div'); box.className = 'box transition-all duration-200'; box.id = myId; box.setAttribute('data-level', node.level); if (parentId) { box.setAttribute('data-parent', parentId); } if (isAdmin) { box.ondragover = (event) => handleDragOver(event); box.ondragleave = (event) => handleDragLeave(event); box.ondrop = (event) => handleDrop(event, node.level, node.name); } box.classList.add(`box-level-${node.level}`); if (node.level === '팀') { box.classList.add('box-team'); } const displayTitle = `${node.name} (${node.totalCount || 0})`; const nodeTitleClass = isAdmin ? 'clickable-title' : ''; box.innerHTML = `
${displayTitle}
`; const memberGrid = document.createElement('div'); const isHighLevel = node.level !== '팀' && node.level !== '셀'; memberGrid.className = isHighLevel ? 'flex flex-col w-full' : 'member-grid'; if (node.level === '팀') { const teamItems = collectTeamItems(node); const leaderIdx = teamItems.findIndex((item) => item['직책'] === '팀장'); const finalItems = []; if (leaderIdx !== -1) { finalItems.push(teamItems.splice(leaderIdx, 1)[0]); } else if (teamItems.length > 0) { finalItems.push(teamItems.shift()); } while (teamItems.length > 0) { const nextIndex = finalItems.length; if (nextIndex > 0 && nextIndex % 10 === 0) { finalItems.push({ isSpacer: true }); continue; } if (nextIndex % 10 === 9 && teamItems[0].isCellHeader) { finalItems.push({ isSpacer: true }); continue; } finalItems.push(teamItems.shift()); } finalItems.forEach((item) => { if (item.isSpacer) { const spacer = document.createElement('div'); spacer.className = 'spacer-box'; memberGrid.appendChild(spacer); } else if (item.isCellHeader) { const label = document.createElement('div'); label.className = `cell-label${isAdmin ? ' clickable-title' : ''}`; label.innerText = item.name; if (isAdmin) { label.onclick = () => openOrgEditModal('셀', item.name); } memberGrid.appendChild(label); } else { memberGrid.appendChild(createMemberCard(item)); } }); } else { const isFullWidth = node.level !== '팀' && node.level !== '셀'; node.members.forEach((member) => memberGrid.appendChild(createMemberCard(member, isFullWidth))); } box.appendChild(memberGrid); nodeItem.appendChild(box); if (node.level !== '팀' && node.children && node.children.length > 0) { const childrenWrapper = document.createElement('div'); childrenWrapper.className = 'node-group'; node.children.forEach((child) => childrenWrapper.appendChild(createNodeDOM(child, myId))); nodeItem.appendChild(childrenWrapper); } return nodeItem; } function applyMemberPlacementFromTarget(member, targetLevel, targetName, targetMember = null) { const targetLevelIndex = levelOrder.indexOf(targetLevel); if (targetLevelIndex === -1) { return member; } for (let index = 0; index <= targetLevelIndex; index += 1) { member[levelOrder[index]] = targetMember ? (targetMember[levelOrder[index]] || '') : (index === targetLevelIndex ? targetName : member[levelOrder[index]]); } if (!targetMember) { member[targetLevel] = targetName; } for (let index = targetLevelIndex + 1; index < levelOrder.length; index += 1) { member[levelOrder[index]] = ''; } rebuildMemberPath(member); return member; } function drawLines() { const container = document.getElementById('tree-root'); const svg = document.getElementById('svg-canvas'); if (!svg || !container) { return; } const containerRect = container.getBoundingClientRect(); let paths = ''; container.querySelectorAll('[data-parent]').forEach((box) => { const parentId = box.getAttribute('data-parent'); const parentBox = document.getElementById(parentId); if (!parentBox) { return; } const parentLevel = parentBox.getAttribute('data-level'); const childLevel = box.getAttribute('data-level'); if (parentLevel === '부서' && childLevel === '그룹') { return; } const parentRect = parentBox.getBoundingClientRect(); const childRect = box.getBoundingClientRect(); const startX = parentRect.left + parentRect.width / 2 - containerRect.left; const startY = parentRect.bottom - containerRect.top; const endX = childRect.left + childRect.width / 2 - containerRect.left; const endY = childRect.top - containerRect.top; const curveY = endY - 20; paths += ``; }); svg.innerHTML = paths; } function updateStatsTable() { if (!members.length) { document.getElementById('stats-table-container').innerHTML = ''; document.getElementById('total-count-badge').innerText = '0명'; return; } const companies = ['한맥', '삼안', '피티씨', '바론']; const rankGroups = { 경영진: ['사장', '부사장'], 수석: ['수석'], 책임: ['책임'], 선임: ['선임'], 연구: ['연구'], }; const columns = Object.keys(rankGroups); const stats = {}; const companyLabelHtml = (company) => ` ${company} `; companies.forEach((company) => { stats[company] = {}; columns.forEach((column) => { stats[company][column] = 0; }); stats[company]._total = 0; }); const targetMembers = selectedDept === '전체' ? members : members.filter((member) => member['부서'] === selectedDept); targetMembers.forEach((member) => { const company = companies.find((item) => (member['소속회사'] || '').includes(item)); if (!company) { return; } const rank = member['직급'] || ''; for (const [groupName, keywords] of Object.entries(rankGroups)) { if (keywords.some((keyword) => rank.includes(keyword))) { stats[company][groupName] += 1; break; } } stats[company]._total += 1; }); let html = `${columns.map((column) => ``).join('')}`; const colSums = {}; columns.forEach((column) => { colSums[column] = 0; }); let grandTotal = 0; companies.forEach((company) => { html += `${columns.map((column) => { colSums[column] += stats[company][column]; return ``; }).join('')}`; grandTotal += stats[company]._total; }); html += `${columns.map((column) => ``).join('')}
구분${column}합계
${companyLabelHtml(company)}${stats[company][column] || '-'}${stats[company]._total}
전체 합계${colSums[column]}${grandTotal}
`; document.getElementById('stats-table-container').innerHTML = html; document.getElementById('total-count-badge').innerText = `${grandTotal}명`; } function render() { const container = document.getElementById('tree-root'); container.innerHTML = ''; if (!members.length) { container.innerHTML += `
${emptyStateMessage}
`; updateStatsTable(); return; } const allDepts = Array.from(new Set(members.map((member) => member['부서']).filter(Boolean))).sort(); updateDeptTabs(['전체', ...allDepts]); const deptNames = selectedDept === '전체' ? allDepts : [selectedDept]; deptNames.forEach((deptName) => { const deptData = members.filter((member) => member['부서'] === deptName); const hierarchy = buildHierarchy(deptData, 0); const deptNode = hierarchy[0] || null; if (deptNode) { calculateTotalCount(deptNode); } const deptSection = document.createElement('div'); deptSection.className = 'dept-section'; const deptId = `node-${encodeURIComponent(`부서_${deptName}`)}`; const hasMembers = Boolean(deptNode && deptNode.members && deptNode.members.length > 0); const totalCount = deptNode ? deptNode.totalCount : deptData.length; const deptBox = document.createElement('div'); deptBox.id = deptId; deptBox.className = 'dept-box'; deptBox.setAttribute('data-level', '부서'); if (isAdmin) { deptBox.ondragover = (event) => handleDragOver(event); deptBox.ondragleave = (event) => handleDragLeave(event); deptBox.ondrop = (event) => handleDrop(event, '부서', deptName); } deptBox.innerHTML = `
${deptName} (${totalCount})
`; if (hasMembers) { const memberGrid = document.createElement('div'); memberGrid.className = 'flex flex-col w-full'; memberGrid.style.padding = '0 15px 15px 15px'; deptNode.members.forEach((member) => memberGrid.appendChild(createMemberCard(member, true))); deptBox.appendChild(memberGrid); } deptSection.appendChild(deptBox); const groupContainer = document.createElement('div'); groupContainer.className = 'node-group'; if (deptNode && deptNode.children) { deptNode.children.forEach((child) => groupContainer.appendChild(createNodeDOM(child, deptId))); } deptSection.appendChild(groupContainer); container.appendChild(deptSection); }); updateStatsTable(); setTimeout(drawLines, 50); } function toggleAdminMode(checked) { if (checked && isHistoricalSnapshot) { alert('월말 히스토리 조회 중에는 수정할 수 없습니다. 최신 월로 돌아간 뒤 수정해주세요.'); return; } isAdmin = checked; const button = document.getElementById('admin-mode-btn'); if (isAdmin) { button.classList.add('is-admin'); button.innerText = '🔓'; button.setAttribute('data-label', '관리자 모드: ON'); } else { button.classList.remove('is-admin'); button.innerText = '🔐'; button.setAttribute('data-label', '관리자 모드: OFF'); } updateFabMenu(); render(); } function toggleFab(event) { if (event) { event.stopPropagation(); } document.getElementById('fab-container').classList.toggle('active'); } function updateFabMenu() { const menu = document.getElementById('fab-menu'); let html = ''; html += ''; html += ''; if (isAdmin && !isHistoricalSnapshot) { html += ''; html += ''; html += ''; } menu.innerHTML = html; } async function openHistoryCompareModal(fromDate, toDate) { openListViewModal(); const fromInput = document.getElementById('list-compare-from'); const toInput = document.getElementById('list-compare-to'); if (fromInput) { fromInput.value = fromDate || ''; } if (toInput) { toInput.value = toDate || ''; } await loadCompareListView(); } function openSeatMapView(event) { event.stopPropagation(); document.getElementById('fab-container').classList.remove('active'); if (window.parent && window.parent !== window) { window.parent.postMessage({ type: 'open-seatmap', readOnly: !isAdmin }, '*'); } } window.addEventListener('message', (event) => { const data = event.data; if (!data || typeof data !== 'object') { return; } if (data.type === 'date-range') { activeAsOfDate = String(data.endDate || '').slice(0, 10); return; } if (data.type === 'organization-history-view') { activeAsOfDate = String(data.asOfDate || '').slice(0, 10); isHistoricalSnapshot = Boolean(data.historical); if (isHistoricalSnapshot && isAdmin) { toggleAdminMode(false); } else { updateFabMenu(); render(); } seatMapLayoutCache = null; loadMembers().catch(() => { }); return; } if (data.type === 'open-history-compare') { openHistoryCompareModal(String(data.fromDate || ''), String(data.toDate || '')).catch((error) => { alert(error.message || '변경 비교를 불러오지 못했습니다.'); }); return; } if (data.type === 'seatmap-layout-updated') { handleSeatMapLayoutUpdated(); } }); function triggerUpload(event) { if (event) { event.stopPropagation(); } document.getElementById('upload-excel').click(); } function printA3() { window.print(); } function toggleStats() { const container = document.getElementById('stats-table-container'); const icon = document.getElementById('stats-toggle-icon'); const area = document.getElementById('stats-area'); if (container.style.display === 'none') { container.style.display = 'block'; icon.style.transform = 'rotate(0deg)'; area.style.padding = '15px'; } else { container.style.display = 'none'; icon.style.transform = 'rotate(-90deg)'; area.style.padding = '10px 15px'; } } function handleSearch(value) { const query = value.trim().toLowerCase(); if (!query) { return; } document.querySelectorAll('.search-target').forEach((element) => element.classList.remove('search-target')); let targetEl = null; const memberMatch = members.find((member) => (member['이름'] || '').toLowerCase().includes(query)); if (memberMatch) { targetEl = document.getElementById(`card-${memberMatch._id}`); } if (!targetEl) { for (const level of levelOrder) { const orgName = Array.from(new Set(members.map((member) => member[level]).filter(Boolean))) .find((name) => name.toLowerCase().includes(query)); if (orgName) { targetEl = document.getElementById(`node-${encodeURIComponent(`${level}_${orgName}`)}`); } if (targetEl) { break; } } } if (!targetEl) { alert('검색 결과를 찾을 수 없습니다.'); return; } targetEl.scrollIntoView({ behavior: 'smooth', block: 'center' }); targetEl.classList.add('search-target'); } async function importMemberFile(file) { const formData = new FormData(); formData.append('file', file); const payload = await apiFetch('/api/members/import', { method: 'POST', body: formData, }); emptyStateMessage = '서버에 조직 데이터가 없습니다. 상단의 업로드 버튼으로 초기 데이터를 넣어주세요.'; setMembers(payload.items || []); render(); } function openAddModal(event) { if (event) { event.stopPropagation(); } openModal(null); } function renderListViewShell(defaultDate) { const modal = document.getElementById('modal'); modal.querySelector('.modal-content').classList.add('wide'); document.getElementById('modal-title').innerText = '인원 명단'; const fieldsArea = document.getElementById('modal-fields'); fieldsArea.className = 'flex flex-col w-full overflow-hidden'; fieldsArea.style.maxHeight = '75vh'; fieldsArea.style.overflowY = 'hidden'; fieldsArea.innerHTML = `
~
`; modal.style.display = 'flex'; } function returnToListViewModal() { if (!isListMode) { return; } isListDetailModal = false; renderListViewShell(listViewState.snapshotDate || getDefaultHistoryDate()); renderListViewModalContent(); } function updateParentList() { const type = document.getElementById('new-unit-type').value; const parentSelect = document.getElementById('new-unit-parent'); const typeIdx = levelOrder.indexOf(type); const parentType = levelOrder[typeIdx - 1]; const parents = Array.from(new Set(members.map((member) => member[parentType]).filter(Boolean))).sort(); parentSelect.innerHTML = '' + parents.map((parent) => ``).join(''); } function openUnitAddModal(event) { if (event) { event.stopPropagation(); } if (!members.length) { alert('먼저 조직 데이터를 업로드해주세요.'); return; } const modal = document.getElementById('modal'); modal.querySelector('.modal-content').classList.remove('wide'); document.getElementById('modal-title').innerText = '신규 조직 단위 추가'; const fieldsArea = document.getElementById('modal-fields'); fieldsArea.className = 'grid grid-cols-2 gap-x-8 gap-y-5'; fieldsArea.style.maxHeight = 'none'; fieldsArea.innerHTML = `
`; updateParentList(); document.getElementById('modal-footer-area').innerHTML = ` `; modal.style.display = 'flex'; } async function saveNewUnit() { const type = document.getElementById('new-unit-type').value; const parentName = document.getElementById('new-unit-parent').value; const name = document.getElementById('new-unit-name').value.trim(); if (!name) { alert('이름을 입력해주세요.'); return; } const typeIdx = levelOrder.indexOf(type); const parentType = levelOrder[typeIdx - 1]; const template = (parentName && parentType) ? (members.find((member) => member[parentType] === parentName) || members[0]) : members[0]; const newMember = { ...cloneMembers([template])[0], _id: `virtual-${Date.now()}`, 이름: '공석(신규)', 직급: '', 직책: '', 소속회사: template?.소속회사 || '', 전화번호: '', 이메일: '', 자리위치: '', 사진: '', 근무상태: '근무', 근무시간: '09~18', }; if (!parentName) { for (let index = 1; index < typeIdx; index += 1) { newMember[levelOrder[index]] = ''; } } else { newMember[parentType] = parentName; } newMember[type] = name; for (let index = typeIdx + 1; index < levelOrder.length; index += 1) { newMember[levelOrder[index]] = ''; } rebuildMemberPath(newMember); await syncMembers([...members, newMember]); closeModal(); } function openOrgEditModal(level, oldName) { const modal = document.getElementById('modal'); modal.querySelector('.modal-content').classList.remove('wide'); document.getElementById('modal-title').innerText = `${level} 이름 수정`; const fieldsArea = document.getElementById('modal-fields'); fieldsArea.className = 'grid grid-cols-2 gap-x-8 gap-y-5'; fieldsArea.style.maxHeight = 'none'; fieldsArea.innerHTML = `
`; document.getElementById('modal-footer-area').innerHTML = ` `; modal.style.display = 'flex'; } async function deleteOrg(level, name) { if (!confirm(`'${name}' ${level}과 소속된 모든 인원 정보가 삭제됩니다. 정말 삭제하시겠습니까?`)) { return; } const nextMembers = members.filter((member) => member[level] !== name); await syncMembers(nextMembers); closeModal(); } async function saveOrgName(level, oldName) { const newName = document.getElementById('new-org-name').value.trim(); if (!newName) { alert('이름을 입력해주세요.'); return; } if (newName === oldName) { closeModal(); return; } const nextMembers = cloneMembers(members); nextMembers.forEach((member) => { if (member[level] === oldName) { member[level] = newName; rebuildMemberPath(member); } }); await syncMembers(nextMembers); closeModal(); } function toggleManualInput(field) { document.getElementById(`manual-${field}`).classList.toggle('hidden', document.getElementById(`sel-${field}`).value !== '__NEW__'); } function toggleFlexibleTime(value) { document.getElementById('flexible-time-area').classList.toggle('hidden', value !== '유연근무제'); } function updatePhotoPreview(src, fallbackName) { const preview = document.getElementById('m-photo-preview'); if (!preview) { return; } preview.src = src || getPhotoPlaceholder(fallbackName); } function syncPhotoPreviewFromUrl() { const name = document.getElementById('m-name')?.value?.trim() || ''; const url = document.getElementById('m-photo-hidden')?.value?.trim() || ''; updatePhotoPreview(url, name); } function handlePhotoFileChange(event) { const file = event.target.files?.[0]; const fileName = document.getElementById('m-photo-file-name'); const name = document.getElementById('m-name')?.value?.trim() || ''; resetPhotoPreviewObjectUrl(); if (!file) { if (fileName) { fileName.textContent = '선택된 파일 없음'; } syncPhotoPreviewFromUrl(); return; } if (fileName) { fileName.textContent = file.name; } photoPreviewObjectUrl = URL.createObjectURL(file); updatePhotoPreview(photoPreviewObjectUrl, name); } function renderSeatPreviewCard(seatInfo) { const assigned = Boolean(seatInfo?.assigned); const seatMapLabel = String(seatInfo?.seatMapName || '자리배치도').replace(/\s*자리배치도\s*$/u, '').trim() || '사무실'; const safeSeatMapName = escapeHtml(seatInfo?.seatMapName || '자리배치도'); const safeOfficeLabel = escapeHtml(seatMapLabel); const badge = assigned ? `${safeOfficeLabel}` : '미배치'; const body = assigned ? ` ` : `
현재 공석 또는 미배치 상태입니다.
`; return `
재석위치

${assigned ? '현재 배치된 사무실과 좌석 위치를 강조해서 표시합니다.' : '현재 자리배치도에서 배치된 좌석이 없습니다.'}

${badge}
${body}
`; } async function hydrateMemberSeatPreview(member) { const target = document.getElementById('member-seat-preview'); if (!target) { return; } target.innerHTML = renderSeatPreviewCard({ assigned: false, seatMapName: '자리배치도', seatLabel: member['자리위치'] || '', slotKey: '', }); const layouts = await loadSeatMapLayouts(true); if (!document.getElementById('member-seat-preview')) { return; } const seatInfo = getMemberSeatInfo(layouts, member.id) || { layout: null, seatMapName: '자리배치도', seatLabel: member['자리위치'] || '', slotKey: '', assigned: false, }; target.innerHTML = renderSeatPreviewCard(seatInfo); if (!seatInfo.assigned || !seatInfo.seatMapId || !seatInfo.slotKey) { return; } const frame = document.getElementById('member-seat-preview-frame'); if (!frame) { return; } frame.addEventListener('load', () => { applySeatPreviewFrameState(frame, seatInfo, seatInfo.layout); }, { once: true }); } function switchModalTab(tab) { const isBasic = tab === 'basic'; document.getElementById('modal-sec-basic').classList.toggle('hidden', !isBasic); document.getElementById('modal-sec-org').classList.toggle('hidden', isBasic); document.getElementById('modal-tab-basic').className = isBasic ? 'member-modal-tab is-active' : 'member-modal-tab'; document.getElementById('modal-tab-org').className = !isBasic ? 'member-modal-tab is-active' : 'member-modal-tab'; } function openModal(id) { const sourceList = isListMode ? editingMembers : members; const modal = document.getElementById('modal'); modal.querySelector('.modal-content').classList.remove('wide'); const fieldsArea = document.getElementById('modal-fields'); const footer = document.getElementById('modal-footer-area'); const member = id ? (sourceList.find((item) => item._id === id) || {}) : {}; if (!isAdmin && id) { document.getElementById('modal-title').innerText = '구성원 상세 프로필'; fieldsArea.className = 'flex flex-col items-center gap-6 py-4'; fieldsArea.style.maxHeight = 'none'; fieldsArea.innerHTML = `

${member['이름'] || ''}

${member['직급'] || '-'} / ${member['직책'] || '팀원'}

${(member._path || []).map((path) => path.name).join(' > ')}

${member['전화번호'] || '정보 없음'}
${member['이메일'] || '정보 없음'}
${renderSeatPreviewCard({ assigned: false, seatLabel: member['자리위치'] || '', seatMapName: '자리배치도', slotKey: '' })}
`; footer.innerHTML = ''; modal.style.display = 'flex'; hydrateMemberSeatPreview(member); return; } document.getElementById('modal-title').innerText = id ? '구성원 정보 수정' : '신규 구성원 추가'; modal.querySelector('.modal-content').classList.add('wide'); fieldsArea.className = 'flex flex-col w-full'; fieldsArea.style.maxHeight = 'none'; fieldsArea.style.overflowY = 'auto'; isListDetailModal = isListMode; const sourceValues = isListMode ? editingMembers : members; let orgFields = '`; fieldsArea.innerHTML = `
${orgFields}
`; resetPhotoPreviewObjectUrl(); const deleteBtn = id ? `` : ''; footer.innerHTML = ` ${deleteBtn} `; modal.style.display = 'flex'; if (id) { hydrateMemberSeatPreview(member); } } function closeModal() { if (isListMode && isListDetailModal) { returnToListViewModal(); return; } resetPhotoPreviewObjectUrl(); document.getElementById('modal').style.display = 'none'; document.getElementById('modal-fields').className = 'grid grid-cols-2 gap-x-8 gap-y-5'; document.getElementById('modal-fields').style.maxHeight = 'none'; document.getElementById('modal-fields').style.overflowY = 'visible'; document.querySelector('.modal-content').classList.remove('wide'); isListDetailModal = false; isListMode = false; } async function saveMember() { const id = document.getElementById('m-id')?.value || ''; const name = document.getElementById('m-name').value.trim(); if (!name) { alert('이름을 입력해주세요.'); return; } const targetList = isListMode ? editingMembers : members; let member = id ? targetList.find((item) => item._id === id) : { _id: `virtual-${Date.now()}` }; if (!member) { member = { _id: `virtual-${Date.now()}` }; } member['이름'] = name; member['사번'] = document.getElementById('m-employee-id').value.trim(); dropdownFields.forEach((field) => { const selectValue = document.getElementById(`sel-${field}`).value; if (selectValue === '__NEW__') { member[field] = document.getElementById(`input-${field}`).value.trim(); } else if (selectValue === '__NONE__') { member[field] = ''; } else { member[field] = selectValue; } }); member['근무상태'] = document.getElementById('m-status').value; member['근무시간'] = document.getElementById('m-worktime').value; member['전화번호'] = document.getElementById('m-phone').value.trim(); member['이메일'] = document.getElementById('m-email').value.trim(); member['자리위치'] = document.getElementById('m-seat-hidden').value.trim(); member['사진'] = document.getElementById('m-photo-hidden').value.trim(); const photoFile = document.getElementById('m-photo-file')?.files?.[0]; if (photoFile) { member['사진'] = await uploadProfilePhoto(photoFile, member['이름']); } if (member['근무시간'] === '유연근무제') { member['유연근무_시작'] = document.getElementById('m-work-start').value; member['유연근무_종료'] = document.getElementById('m-work-end').value; } else { member['유연근무_시작'] = ''; member['유연근무_종료'] = ''; } rebuildMemberPath(member); if (isListMode) { if (!id) { targetList.push(member); } returnToListViewModal(); return; } if (id && member.id) { await apiFetch(`/api/members/${member.id}`, { method: 'PUT', body: JSON.stringify({ ...toApiMember(member, member.sort_order || 0), id: member.id, }), }); } else { await apiFetch('/api/members', { method: 'POST', body: JSON.stringify(toApiMember(member, members.length)), }); } await loadMembers(); closeModal(); } async function deleteMember(id) { if (!confirm('해당 구성원을 삭제하시겠습니까?')) { return; } if (isListMode) { const shouldReturnToList = isListDetailModal; const idx = editingMembers.findIndex((member) => member._id === id); if (idx !== -1) { editingMembers.splice(idx, 1); } if (shouldReturnToList) { returnToListViewModal(); } else { renderListViewTable(); } return; } const member = members.find((item) => item._id === id); if (!member?.id) { return; } await apiFetch(`/api/members/${member.id}`, { method: 'DELETE' }); await loadMembers(); closeModal(); } function openListViewModal(event) { if (event) { event.stopPropagation(); } const defaultDate = getDefaultHistoryDate(); listViewState.mode = 'current'; listViewState.snapshotDate = defaultDate; listViewState.compareFromDate = defaultDate; listViewState.compareToDate = defaultDate; listViewState.snapshotMembers = []; listViewState.compareItems = []; isListMode = true; isListDetailModal = false; editingMembers = cloneMembers(members); renderListViewShell(defaultDate); renderListViewModalContent(); } async function applyListViewChanges() { if (listViewState.mode !== 'current') { closeModal(); return; } if (!confirm('리스트의 변경 사항을 메인 화면에 반영하시겠습니까?')) { return; } await syncMembers(editingMembers); isListMode = false; closeModal(); } function renderListViewFooter() { const footer = document.getElementById('modal-footer-area'); if (!footer) { return; } if (listViewState.mode === 'current' && isAdmin) { footer.innerHTML = `

항목을 드래그하여 순서를 바꿀 수 있습니다.

`; return; } footer.innerHTML = '
'; } function getRenderableListMembers() { if (listViewState.mode === 'snapshot') { return listViewState.snapshotMembers; } return editingMembers; } function getListSearchEntries() { if (listViewState.mode === 'compare') { return (listViewState.compareItems || []).map((item) => ({ rowId: `list-compare-row-${item.member_id}`, name: String(item.name || ''), values: [String(item.name || ''), ...(item.before_lines || []), ...(item.after_lines || [])], })); } return getRenderableListMembers().map((member) => ({ rowId: `list-row-${member._id}`, name: String(member['이름'] || ''), values: [ String(member['이름'] || ''), ...levelOrder.map((level) => String(member[level] || '')), ], })); } function formatCompareChangedAt(value) { const raw = String(value || '').trim(); if (!raw) { return '-'; } const date = new Date(raw); if (Number.isNaN(date.getTime())) { return raw; } const year = date.getFullYear(); const month = pad(date.getMonth() + 1); const day = pad(date.getDate()); const hours = pad(date.getHours()); const minutes = pad(date.getMinutes()); return `${year}-${month}-${day} ${hours}:${minutes}`; } function renderListViewCompareTable() { const container = document.getElementById('list-table-container'); if (!container) { return; } const rows = listViewState.compareItems || []; let html = ` `; if (!rows.length) { html += ''; } else { rows.forEach((item) => { const categories = (item.categories || []).map((category) => `${escapeHtml(category)}`).join(''); const beforeLines = (item.before_lines || []).map((line) => `
${escapeHtml(line)}
`).join('') || '-'; const afterLines = (item.after_lines || []).map((line) => `
${escapeHtml(line)}
`).join('') || '-'; html += ` `; }); } html += '
이름 상태 변경일시 변경유형 이전 현재
선택한 기간 사이의 구성원 변경 내역이 없습니다.
${escapeHtml(item.name || '-')} ${escapeHtml(item.status_label || '-')} ${escapeHtml(formatCompareChangedAt(item.changed_at))}
${categories || '-'}
${beforeLines} ${afterLines}
'; container.innerHTML = html; } function renderListViewModalContent() { const status = document.getElementById('list-view-status'); if (status) { if (listViewState.mode === 'snapshot') { status.textContent = listViewState.snapshotDate ? `${listViewState.snapshotDate} 기준 인원 명단입니다.` : '기준일을 선택한 뒤 조회하세요.'; } else if (listViewState.mode === 'compare') { status.textContent = (listViewState.compareFromDate && listViewState.compareToDate) ? `${listViewState.compareFromDate} ~ ${listViewState.compareToDate} 변경 내역입니다.` : '비교 시작일과 종료일을 선택하세요.'; } else { status.textContent = '현재 조직 인원 명단입니다.'; } } if (listViewState.mode === 'compare') { renderListViewCompareTable(); } else { renderListViewTable(); } renderListViewFooter(); } function showCurrentListView() { listViewState.mode = 'current'; renderListViewModalContent(); } async function loadSnapshotListView() { const snapshotDate = document.getElementById('list-snapshot-date')?.value || ''; if (!snapshotDate) { alert('기준일을 선택해주세요.'); return; } const payload = await apiFetch(`/api/members?as_of=${encodeURIComponent(snapshotDate)}`); listViewState.snapshotDate = snapshotDate; listViewState.snapshotMembers = getVisibleLegacyMembers((payload.items || []).map(toLegacyMember)); listViewState.mode = 'snapshot'; renderListViewModalContent(); } async function loadCompareListView() { const fromDate = document.getElementById('list-compare-from')?.value || ''; const toDate = document.getElementById('list-compare-to')?.value || ''; if (!fromDate || !toDate) { alert('비교 시작일과 종료일을 선택해주세요.'); return; } const payload = await apiFetch(`/api/history/members/compare?from_date=${encodeURIComponent(fromDate)}&to_date=${encodeURIComponent(toDate)}`); listViewState.compareFromDate = fromDate; listViewState.compareToDate = toDate; listViewState.compareItems = Array.isArray(payload.items) ? payload.items : []; listViewState.mode = 'compare'; renderListViewModalContent(); } function renderListViewTable() { const container = document.getElementById('list-table-container'); if (!container) { return; } const sourceMembers = getRenderableListMembers(); const editable = isAdmin && listViewState.mode === 'current'; const inspectable = !editable && listViewState.mode === 'current'; const groupColumnCount = editable ? 11 : 10; let html = `${editable ? '' : ''}`; const lastValues = {}; levelOrder.forEach((level) => { lastValues[level] = ''; }); sourceMembers.forEach((member, index) => { let isAnyParentCollapsed = false; levelOrder.forEach((level, depth) => { const value = (member[level] || '').trim(); if (!value) { return; } const key = `${level}_${value}`; const parentLevels = levelOrder.slice(0, depth); if (parentLevels.some((parentLevel) => member[parentLevel] && collapsedUnits.has(`${parentLevel}_${member[parentLevel].trim()}`))) { isAnyParentCollapsed = true; } if (value !== lastValues[level]) { const isCollapsed = collapsedUnits.has(key); const dragAttr = editable ? `draggable="true" ondragstart="handleListGroupDragStart(event, '${jsString(level)}', '${jsString(value)}')" ondragover="event.preventDefault()" ondrop="handleListGroupDrop(event, '${jsString(level)}', '${jsString(value)}')"` : ''; html += ``; lastValues[level] = value; levelOrder.slice(depth + 1).forEach((childLevel) => { lastValues[childLevel] = ''; }); } }); const hidden = levelOrder.some((level) => member[level] && collapsedUnits.has(`${level}_${member[level].trim()}`)) || isAnyParentCollapsed; const rowDragAttr = editable ? `draggable="true" ondragstart="handleListDragStart(event, ${index})" ondragover="event.preventDefault()" ondrop="handleListDrop(event, ${index})"` : ''; const actionCell = editable ? `
수정삭제
` : inspectable ? `조회` : '-'; html += ` ${editable ? '' : ''} `; }); html += '
순서이름직급직책디비전그룹부서소속${editable ? '관리' : '조회'}
${escapeHtml(value)}
${escapeHtml(member['이름'] || '-')} ${escapeHtml(member['직급'] || '-')} ${member['근무상태'] === '휴직' ? '휴직' : escapeHtml(member['직책'] || '-')} ${escapeHtml(member['셀'] || '-')} ${escapeHtml(member['팀'] || '-')} ${escapeHtml(member['디비전'] || '-')} ${escapeHtml(member['그룹'] || '-')} ${escapeHtml(member['부서'] || '-')} ${escapeHtml(member['소속회사'] || '-')} ${actionCell}
'; container.innerHTML = html; } function toggleUnitCollapse(level, name) { const key = `${level}_${name}`; if (collapsedUnits.has(key)) { collapsedUnits.delete(key); } else { collapsedUnits.add(key); } renderListViewTable(); } let draggedGroup = null; function handleListGroupDragStart(event, level, name) { draggedIdx = null; draggedGroup = { level, name }; event.dataTransfer.effectAllowed = 'move'; } function handleListGroupDrop(event, targetLevel, targetName) { event.preventDefault(); if (draggedIdx !== null) { const movingMember = editingMembers[draggedIdx]; if (!movingMember) { return; } const moved = editingMembers.splice(draggedIdx, 1)[0]; applyMemberPlacementFromTarget(moved, targetLevel, targetName, null); let targetIdx = editingMembers.findIndex((member) => member[targetLevel] === targetName); if (targetIdx === -1) { targetIdx = editingMembers.length; } editingMembers.splice(targetIdx, 0, moved); draggedIdx = null; renderListViewTable(); return; } if (!draggedGroup || (draggedGroup.level === targetLevel && draggedGroup.name === targetName)) { return; } const movingMembers = editingMembers.filter((member) => member[draggedGroup.level] === draggedGroup.name); if (!movingMembers.length) { return; } editingMembers = editingMembers.filter((member) => member[draggedGroup.level] !== draggedGroup.name); let targetIdx = editingMembers.findIndex((member) => member[targetLevel] === targetName); if (targetIdx === -1) { targetIdx = editingMembers.length; } editingMembers.splice(targetIdx, 0, ...movingMembers); draggedGroup = null; renderListViewTable(); } function handleListSearch(value) { const query = value.trim().toLowerCase(); if (!query) { return; } document.querySelectorAll('.list-search-target').forEach((element) => element.classList.remove('list-search-target')); const targetEntry = getListSearchEntries().find((entry) => ( entry.values.some((candidate) => String(candidate || '').toLowerCase().includes(query)) )); if (!targetEntry) { alert('검색 결과가 없습니다.'); return; } const row = document.getElementById(targetEntry.rowId); if (row) { row.scrollIntoView({ behavior: 'smooth', block: 'center' }); row.classList.add('list-search-target'); setTimeout(() => row.classList.remove('list-search-target'), 2000); } } let draggedIdx = null; function handleListDragStart(event, index) { draggedGroup = null; draggedIdx = index; event.dataTransfer.effectAllowed = 'move'; event.target.classList.add('dragging'); } function handleListDrop(event, targetIdx) { event.preventDefault(); if (draggedIdx === null || draggedIdx === targetIdx) { return; } const targetMember = editingMembers[targetIdx] || null; const moved = editingMembers.splice(draggedIdx, 1)[0]; if (targetMember) { applyMemberPlacementFromTarget(moved, levelOrder[levelOrder.length - 1], targetMember[levelOrder[levelOrder.length - 1]] || '', targetMember); } editingMembers.splice(targetIdx, 0, moved); draggedIdx = null; renderListViewTable(); } function handleDragStart(event, type, id) { event.stopPropagation(); if (type !== 'member') { return; } event.dataTransfer.setData('text/plain', JSON.stringify({ type, id })); event.dataTransfer.effectAllowed = 'move'; setTimeout(() => event.target.classList.add('opacity-40', 'scale-95'), 0); } function handleDragEnd(event) { event.stopPropagation(); event.target.classList.remove('opacity-40', 'scale-95'); } function handleDragOver(event) { event.preventDefault(); event.stopPropagation(); event.dataTransfer.dropEffect = 'move'; event.currentTarget.classList.add('ring-4', 'ring-indigo-400', 'bg-indigo-50'); } function handleDragLeave(event) { event.stopPropagation(); event.currentTarget.classList.remove('ring-4', 'ring-indigo-400', 'bg-indigo-50'); } async function handleDrop(event, targetLevel, targetName) { event.preventDefault(); event.stopPropagation(); event.currentTarget.classList.remove('ring-4', 'ring-indigo-400', 'bg-indigo-50'); try { const data = JSON.parse(event.dataTransfer.getData('text/plain')); if (data.type !== 'member') { return; } const targetMember = members.find((member) => member[targetLevel] === targetName); const targetLevelIndex = levelOrder.indexOf(targetLevel); const memberIndex = members.findIndex((item) => item._id === data.id); if (memberIndex === -1) { return; } const nextMembers = cloneMembers(members); const moved = nextMembers[memberIndex]; if (targetLevelIndex === -1) { return; } applyMemberPlacementFromTarget(moved, targetLevel, targetName, targetMember); nextMembers.splice(memberIndex, 1); nextMembers.push(moved); await syncMembers(nextMembers); } catch (error) { console.error(error); } } function handleDragOverMember(event) { event.preventDefault(); event.stopPropagation(); event.dataTransfer.dropEffect = 'move'; const rect = event.currentTarget.getBoundingClientRect(); const relX = event.clientX - rect.left; if (relX < rect.width / 2) { event.currentTarget.classList.add('drop-left'); event.currentTarget.classList.remove('drop-right'); } else { event.currentTarget.classList.add('drop-right'); event.currentTarget.classList.remove('drop-left'); } } function handleDragLeaveMember(event) { event.stopPropagation(); event.currentTarget.classList.remove('drop-left', 'drop-right'); } async function handleDropMember(event, targetId) { event.preventDefault(); event.stopPropagation(); const rect = event.currentTarget.getBoundingClientRect(); const insertAfter = event.clientX - rect.left >= rect.width / 2; event.currentTarget.classList.remove('drop-left', 'drop-right'); try { const data = JSON.parse(event.dataTransfer.getData('text/plain')); if (data.type !== 'member' || data.id === targetId) { return; } const nextMembers = cloneMembers(members); let movingIdx = nextMembers.findIndex((member) => member._id === data.id); let targetIdx = nextMembers.findIndex((member) => member._id === targetId); if (movingIdx === -1 || targetIdx === -1) { return; } const moved = nextMembers[movingIdx]; const target = nextMembers[targetIdx]; applyMemberPlacementFromTarget(moved, levelOrder[levelOrder.length - 1], target[levelOrder[levelOrder.length - 1]] || '', target); nextMembers.splice(movingIdx, 1); targetIdx = nextMembers.findIndex((member) => member._id === targetId); nextMembers.splice(insertAfter ? targetIdx + 1 : targetIdx, 0, moved); await syncMembers(nextMembers); } catch (error) { console.error(error); } } window.addEventListener('resize', () => { requestAnimationFrame(drawLines); }); window.addEventListener('click', () => { document.getElementById('fab-container').classList.remove('active'); }); document.addEventListener('DOMContentLoaded', async () => { document.getElementById('upload-excel').addEventListener('change', async (event) => { const file = event.target.files?.[0]; if (!file) { return; } try { await importMemberFile(file); event.target.value = ''; } catch (error) { alert(error.message || '업로드에 실패했습니다.'); } }); document.getElementById('admin-mode-btn').addEventListener('click', () => toggleAdminMode(!isAdmin)); document.getElementById('fab-main').addEventListener('click', (event) => toggleFab(event)); document.getElementById('search-input').addEventListener('keydown', (event) => { if (event.key === 'Enter') { handleSearch(event.target.value); } }); document.getElementById('stats-header').addEventListener('click', toggleStats); document.getElementById('modal-cancel-btn').addEventListener('click', closeModal); updateFabMenu(); try { await loadMembers('서버에서 조직 데이터를 불러오는 중입니다.'); } catch (error) { console.error(error); emptyStateMessage = `WSL 서버 연결에 실패했습니다. ${error.message || ''}`; render(); } });