Files
MH-DashBoard-organization/legacy/static/organization.js
2026-03-25 12:00:24 +09:00

1330 lines
51 KiB
JavaScript

let members = [];
let isAdmin = false;
let selectedDept = '전체';
let editingMembers = [];
let collapsedUnits = new Set();
let isListMode = false;
let emptyStateMessage = '서버에 조직 데이터가 없습니다. 상단의 업로드 버튼으로 초기 데이터를 넣어주세요.';
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 toLegacyMember(item) {
return rebuildMemberPath({
_id: String(item.id),
id: item.id,
이름: item.name || '',
소속회사: 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['이름'] || '',
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 setMembers(items) {
members = items.map(toLegacyMember);
if (selectedDept !== '전체' && !members.some((member) => member['부서'] === selectedDept)) {
selectedDept = '전체';
}
updateTimestamp();
}
async function loadMembers(message) {
if (message) {
emptyStateMessage = message;
}
const payload = await apiFetch('/api/members');
setMembers(payload.items || []);
if (!members.length) {
emptyStateMessage = '서버에 조직 데이터가 없습니다. 상단의 업로드 버튼으로 초기 데이터를 넣어주세요.';
}
render();
}
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) => `
<div class="dept-tab ${selectedDept === dept ? 'active' : ''}" onclick="selectDept('${jsString(dept)}')">${dept}</div>
`).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
? '<span class="m-role" style="color:#ef4444; border-color:#fee2e2; background:#fff1f2;">휴직</span>'
: ((member['직책'] && member['직책'] !== '팀원') ? `<span class="m-role">${member['직책']}</span>` : '');
card.innerHTML = `<div class="m-top"><span class="m-name">${member['이름']}</span>${roleDisplay}<span class="m-rank">${member['직급'] || ''}</span></div>`;
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 = `<div class="box-name ${nodeTitleClass}" ${isAdmin ? `onclick="openOrgEditModal('${jsString(node.level)}', '${jsString(node.name)}')"` : ''}>${displayTitle}</div>`;
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 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 += `<path d="M ${startX} ${startY} L ${startX} ${curveY} L ${endX} ${curveY} L ${endX} ${endY}" stroke="#cbd5e1" stroke-width="2" fill="none" stroke-linejoin="round" />`;
});
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 = {};
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 = `<table class="stats-table"><thead><tr><th class="row-label">구분</th>${columns.map((column) => `<th>${column}</th>`).join('')}<th>합계</th></tr></thead><tbody>`;
const colSums = {};
columns.forEach((column) => {
colSums[column] = 0;
});
let grandTotal = 0;
companies.forEach((company) => {
html += `<tr><td class="row-label">${company}</td>${columns.map((column) => {
colSums[column] += stats[company][column];
return `<td>${stats[company][column] || '-'}</td>`;
}).join('')}<td class="total-cell">${stats[company]._total}</td></tr>`;
grandTotal += stats[company]._total;
});
html += `<tr class="sum-row"><td class="row-label">전체 합계</td>${columns.map((column) => `<td>${colSums[column]}</td>`).join('')}<td class="total-cell">${grandTotal}</td></tr></tbody></table>`;
document.getElementById('stats-table-container').innerHTML = html;
document.getElementById('total-count-badge').innerText = `${grandTotal}`;
}
function render() {
const container = document.getElementById('tree-root');
container.innerHTML = '<svg id="svg-canvas" style="position:absolute;top:0;left:0;width:100%;height:100%;pointer-events:none;z-index:0;"></svg>';
if (!members.length) {
container.innerHTML += `<div class="text-slate-400 font-bold mt-20 text-xs text-center">${emptyStateMessage}</div>`;
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', '부서');
deptBox.innerHTML = `<div class="dept-header ${isAdmin ? 'clickable-title' : ''} ${hasMembers ? 'has-members' : ''}" ${isAdmin ? `onclick="openOrgEditModal('부서', '${jsString(deptName)}')"` : ''}>${deptName} (${totalCount})</div>`;
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) {
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 = '<button class="fab-sub shadow-xl" data-label="리스트" onclick="openListViewModal(event)">📋</button>';
html += '<button class="fab-sub shadow-xl" data-label="조직도 인쇄(A3)" onclick="printA3()">🖨️</button>';
if (isAdmin) {
html += '<button class="fab-sub shadow-xl" data-label="조직현황 업로드" onclick="triggerUpload(event)">⬆️</button>';
html += '<button class="fab-sub shadow-xl" data-label="신규 구성원" onclick="openAddModal(event)">👤</button>';
html += '<button class="fab-sub shadow-xl" data-label="신규 팀/그룹/셀" onclick="openUnitAddModal(event)">🏢</button>';
}
menu.innerHTML = html;
}
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 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 = '<option value="">-- 선택 안 함 (기본) --</option>' + parents.map((parent) => `<option value="${parent}">${parent}</option>`).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 = `
<div class="col-span-2">
<label class="text-[11px] font-black text-slate-600 block">만들고 싶은 조직 종류</label>
<select id="new-unit-type" onchange="updateParentList()" class="w-full bg-white p-3 rounded-xl border text-sm font-bold outline-none">
${['그룹', '디비전', '팀', '셀'].map((unit) => `<option value="${unit}">${unit}</option>`).join('')}
</select>
</div>
<div class="col-span-2">
<label class="text-[11px] font-black text-slate-600 block">상위 위치 선택</label>
<select id="new-unit-parent" class="w-full bg-white p-3 rounded-xl border text-sm font-bold outline-none"></select>
</div>
<div class="col-span-2">
<label class="text-[11px] font-black text-slate-600 block">신규 명칭 입력</label>
<input id="new-unit-name" placeholder="예: 신규개발팀" class="w-full bg-slate-50 p-3 rounded-xl border font-bold text-sm outline-none">
</div>
`;
updateParentList();
document.getElementById('modal-footer-area').innerHTML = `
<button onclick="closeModal()" class="flex-1 bg-slate-100 py-3.5 rounded-xl font-bold text-sm">취소</button>
<button onclick="saveNewUnit()" class="flex-1 bg-indigo-600 text-white py-3.5 rounded-xl font-bold text-sm">저장</button>
`;
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 = `
<div class="col-span-2">
<label class="text-[11px] font-black text-slate-400 block">새로운 ${level} 명칭</label>
<input id="new-org-name" value="${oldName}" class="w-full bg-slate-50 p-3 rounded-xl border font-bold text-sm outline-none">
</div>
`;
document.getElementById('modal-footer-area').innerHTML = `
<button onclick="deleteOrg('${jsString(level)}', '${jsString(oldName)}')" class="bg-red-50 text-red-600 py-3.5 px-6 rounded-xl font-bold text-sm border border-red-100 hover:bg-red-100 transition-colors">삭제</button>
<button onclick="closeModal()" class="flex-1 bg-slate-100 py-3.5 rounded-xl font-bold text-sm">취소</button>
<button onclick="saveOrgName('${jsString(level)}', '${jsString(oldName)}')" class="flex-1 bg-indigo-600 text-white py-3.5 rounded-xl font-bold text-sm">저장</button>
`;
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 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
? 'flex-1 py-3 font-bold border-b-2 border-indigo-600 text-indigo-600 text-sm transition-all'
: 'flex-1 py-3 font-bold border-b-2 border-transparent text-slate-400 text-sm transition-all';
document.getElementById('modal-tab-org').className = !isBasic
? 'flex-1 py-3 font-bold border-b-2 border-indigo-600 text-indigo-600 text-sm transition-all'
: 'flex-1 py-3 font-bold border-b-2 border-transparent text-slate-400 text-sm transition-all';
}
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 = `
<div class="relative w-32 h-32 rounded-full overflow-hidden border-4 border-indigo-100 shadow-lg">
<img src="${member['사진'] || 'https://via.placeholder.com/120?text=Profile'}" class="w-full h-full object-cover">
</div>
<div class="text-center">
<h2 class="text-2xl font-black text-slate-800">${member['이름'] || ''}</h2>
<p class="text-indigo-600 font-bold">${member['직급'] || '-'} / ${member['직책'] || '팀원'}</p>
<p class="text-slate-400 text-xs mt-1 font-medium">${(member._path || []).map((path) => path.name).join(' > ')}</p>
</div>
<div class="w-full grid grid-cols-2 gap-3 mt-4">
<div class="bg-indigo-50 p-4 rounded-2xl border border-indigo-100 col-span-2 flex items-center gap-4">
<div class="flex-1">
<label class="text-[10px] text-indigo-400 font-bold block mb-1">연락처</label>
<span class="text-sm font-black text-indigo-700">${member['전화번호'] || '정보 없음'}</span>
</div>
<div class="flex-1">
<label class="text-[10px] text-indigo-400 font-bold block mb-1">이메일</label>
<span class="text-sm font-black text-indigo-700">${member['이메일'] || '정보 없음'}</span>
</div>
</div>
<div class="bg-slate-50 p-4 rounded-2xl border border-slate-100 col-span-2">
<label class="text-[10px] text-slate-400 font-bold block mb-1">사무실 위치</label>
<span class="text-sm font-black text-slate-700">${member['자리위치'] || '정보 없음'}</span>
</div>
</div>
`;
footer.innerHTML = '<button onclick="closeModal()" class="w-full bg-slate-800 text-white py-4 rounded-xl font-bold text-sm shadow-lg">닫기</button>';
modal.style.display = 'flex';
return;
}
document.getElementById('modal-title').innerText = id ? '구성원 정보 수정' : '신규 구성원 추가';
fieldsArea.className = 'flex flex-col w-full';
fieldsArea.style.maxHeight = '75vh';
fieldsArea.style.overflowY = 'auto';
const sourceValues = isListMode ? editingMembers : members;
let orgFields = '<div id="modal-sec-org" class="hidden grid grid-cols-2 gap-4">';
dropdownFields.forEach((field) => {
const uniqueValues = Array.from(new Set(sourceValues.map((item) => item[field]).filter(Boolean))).sort();
const currentValue = member[field] || '';
orgFields += `
<div class="col-span-1">
<label class="text-[11px] font-black text-slate-600 block">${field}</label>
<select id="sel-${field}" onchange="toggleManualInput('${field}')" class="w-full bg-white p-3 rounded-xl border text-sm font-bold text-slate-700 outline-none">
<option value="__NEW__" class="text-indigo-600 font-bold">+ 직접/신규 입력</option>
<option value="__NONE__" ${currentValue === '' ? 'selected' : ''}>-- 선택 안 함 --</option>
${uniqueValues.map((value) => `<option value="${value}" ${value === currentValue ? 'selected' : ''}>${value}</option>`).join('')}
</select>
<div id="manual-${field}" class="hidden mt-2">
<input id="input-${field}" placeholder="직접 입력" class="w-full bg-indigo-50 p-3 rounded-xl border-indigo-200 border text-sm font-bold">
</div>
</div>
`;
});
const isFlexible = member['근무시간'] === '유연근무제';
orgFields += `
<div class="col-span-1">
<label class="text-[11px] font-black text-slate-600 block">근무 상태</label>
<select id="m-status" class="w-full bg-white p-3 rounded-xl border text-sm font-bold outline-none">
<option value="근무" ${member['근무상태'] !== '휴직' ? 'selected' : ''}>근무</option>
<option value="휴직" ${member['근무상태'] === '휴직' ? 'selected' : ''}>휴직</option>
</select>
</div>
<div class="col-span-1">
<label class="text-[11px] font-black text-slate-600 block">근무 시간</label>
<select id="m-worktime" onchange="toggleFlexibleTime(this.value)" class="w-full bg-white p-3 rounded-xl border text-sm font-bold outline-none">
<option value="09~18" ${!isFlexible ? 'selected' : ''}>09~18</option>
<option value="유연근무제" ${isFlexible ? 'selected' : ''}>유연근무제</option>
</select>
<div id="flexible-time-area" class="${isFlexible ? '' : 'hidden'} mt-2 flex items-center gap-2">
<input type="time" id="m-work-start" value="${member['유연근무_시작'] || '09:00'}" class="bg-indigo-50 p-2 rounded-lg border border-indigo-100 text-xs font-bold w-full">
<input type="time" id="m-work-end" value="${member['유연근무_종료'] || '18:00'}" class="bg-indigo-50 p-2 rounded-lg border border-indigo-100 text-xs font-bold w-full">
</div>
</div>
</div>`;
fieldsArea.innerHTML = `
<div class="flex border-b mb-6 sticky top-0 bg-white z-10">
<button id="modal-tab-basic" onclick="switchModalTab('basic')" class="flex-1 py-3 font-bold border-b-2 border-indigo-600 text-indigo-600 text-sm transition-all">기본 정보</button>
<button id="modal-tab-org" onclick="switchModalTab('org')" class="flex-1 py-3 font-bold border-b-2 border-transparent text-slate-400 text-sm transition-all">조직 및 근무</button>
</div>
<div id="modal-sec-basic" class="grid grid-cols-2 gap-4">
<input type="hidden" id="m-id" value="${id || ''}">
<div class="col-span-2"><label class="text-[11px] font-black text-slate-600 block">이름 (필수)</label><input id="m-name" value="${member['이름'] || ''}" class="w-full bg-slate-50 p-3 rounded-xl border font-bold text-sm outline-none"></div>
<div class="col-span-1"><label class="text-[11px] font-black text-slate-600 block">전화번호</label><input id="m-phone" value="${member['전화번호'] || ''}" class="w-full bg-white p-3 rounded-xl border font-bold text-sm outline-none"></div>
<div class="col-span-1"><label class="text-[11px] font-black text-slate-600 block">이메일</label><input id="m-email" value="${member['이메일'] || ''}" class="w-full bg-white p-3 rounded-xl border font-bold text-sm outline-none"></div>
<div class="col-span-2"><label class="text-[11px] font-black text-slate-600 block">자리 위치</label><input id="m-seat" value="${member['자리위치'] || ''}" class="w-full bg-white p-3 rounded-xl border font-bold text-sm outline-none"></div>
<div class="col-span-2"><label class="text-[11px] font-black text-slate-600 block">사진 URL</label><input id="m-photo" value="${member['사진'] || ''}" class="w-full bg-white p-3 rounded-xl border font-bold text-sm outline-none text-xs"></div>
</div>
${orgFields}
`;
const deleteBtn = id ? `<button onclick="deleteMember('${id}')" class="bg-red-50 text-red-600 py-3.5 px-6 rounded-xl font-bold text-sm border border-red-100 hover:bg-red-100 transition-colors">삭제</button>` : '';
footer.innerHTML = `
${deleteBtn}
<button onclick="closeModal()" class="flex-1 bg-slate-100 py-3.5 rounded-xl font-bold text-sm">취소</button>
<button onclick="saveMember()" class="flex-1 bg-indigo-600 text-white py-3.5 rounded-xl font-bold text-sm">저장</button>
`;
modal.style.display = 'flex';
}
function closeModal() {
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.querySelector('.modal-content').classList.remove('wide');
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;
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').value.trim();
member['사진'] = document.getElementById('m-photo').value.trim();
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);
}
renderListViewTable();
closeModal();
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 idx = editingMembers.findIndex((member) => member._id === id);
if (idx !== -1) {
editingMembers.splice(idx, 1);
}
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 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';
isListMode = true;
editingMembers = cloneMembers(members);
fieldsArea.innerHTML = `
<div class="mb-4 flex gap-2 p-1">
<input type="text" id="list-search-input" placeholder="이름 또는 부서/팀 검색 (Enter 시 이동)" class="flex-1 bg-slate-50 border-2 border-slate-100 p-3 rounded-xl text-sm outline-none font-bold focus:border-indigo-400 transition-all" onkeydown="if(event.key==='Enter') handleListSearch(this.value)">
<button onclick="handleListSearch(document.getElementById('list-search-input').value)" class="bg-indigo-600 text-white px-5 rounded-xl font-bold text-sm">검색</button>
</div>
<div id="list-table-container" class="overflow-y-auto flex-1 border rounded-xl"></div>
`;
renderListViewTable();
const footer = document.getElementById('modal-footer-area');
if (isAdmin) {
footer.innerHTML = `
<div class="flex gap-2 w-full justify-between items-center">
<div class="flex gap-2">
<button onclick="openAddModal(event)" class="bg-indigo-50 text-indigo-600 px-4 py-2 rounded-lg text-xs font-bold border border-indigo-100">+ 구성원 추가</button>
<button onclick="openUnitAddModal(event)" class="bg-indigo-50 text-indigo-600 px-4 py-2 rounded-lg text-xs font-bold border border-indigo-100">+ 조직 추가</button>
</div>
<div class="flex gap-2 items-center">
<p class="text-[10px] text-slate-400 font-bold mr-4">항목을 드래그하여 순서를 바꿀 수 있습니다.</p>
<button onclick="closeModal()" class="bg-slate-100 text-slate-600 px-6 py-2 rounded-lg text-xs font-bold">취소</button>
<button onclick="applyListViewChanges()" class="bg-indigo-600 text-white px-8 py-2 rounded-lg text-xs font-bold shadow-lg shadow-indigo-200">반영하기</button>
</div>
</div>
`;
} else {
footer.innerHTML = '<div class="flex gap-2 w-full justify-end items-center"><button onclick="closeModal()" class="bg-indigo-600 text-white px-10 py-2.5 rounded-lg text-xs font-bold shadow-lg shadow-indigo-200">닫기</button></div>';
}
modal.style.display = 'flex';
}
async function applyListViewChanges() {
if (!confirm('리스트의 변경 사항을 메인 화면에 반영하시겠습니까?')) {
return;
}
await syncMembers(editingMembers);
isListMode = false;
closeModal();
}
function renderListViewTable() {
const container = document.getElementById('list-table-container');
if (!container) {
return;
}
let html = `<table class="list-table"><thead><tr>${isAdmin ? '<th width="40">순서</th>' : ''}<th class="col-name">이름</th><th class="col-rank">직급</th><th class="col-pos">직책</th><th class="col-unit-sm">셀</th><th class="col-unit-sm">팀</th><th class="col-unit-lg">디비전</th><th class="col-unit-lg">그룹</th><th class="col-unit-lg">부서</th><th class="col-corp">소속</th><th class="col-action">${isAdmin ? '관리' : '조회'}</th></tr></thead><tbody id="list-body">`;
const lastValues = {};
levelOrder.forEach((level) => {
lastValues[level] = '';
});
editingMembers.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 = isAdmin ? `draggable="true" ondragstart="handleListGroupDragStart(event, '${jsString(level)}', '${jsString(value)}')" ondragover="event.preventDefault()" ondrop="handleListGroupDrop(event, '${jsString(level)}', '${jsString(value)}')"` : '';
html += `<tr ${dragAttr} class="list-header-row lvl-${depth} ${isCollapsed ? 'collapsed' : ''} ${isAnyParentCollapsed ? 'hidden-row' : ''}"><td colspan="${(isAdmin ? 10 : 9) + 1}" onclick="toggleUnitCollapse('${jsString(level)}', '${jsString(value)}')" style="padding-left: 15px !important;"><span class="collapse-icon">▼</span> ${value}</td></tr>`;
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 = isAdmin ? `draggable="true" ondragstart="handleListDragStart(event, ${index})" ondragover="event.preventDefault()" ondrop="handleListDrop(event, ${index})"` : '';
html += `
<tr id="list-row-${member._id}" ${rowDragAttr} class="${hidden ? 'hidden-row' : ''}">
${isAdmin ? '<td class="text-slate-300 cursor-move">☰</td>' : ''}
<td class="font-black text-slate-700">${member['이름']}</td>
<td>${member['직급'] || '-'}</td>
<td>${member['근무상태'] === '휴직' ? '<span class="text-red-500 font-black">휴직</span>' : (member['직책'] || '-')}</td>
<td>${member['셀'] || '-'}</td>
<td>${member['팀'] || '-'}</td>
<td>${member['디비전'] || '-'}</td>
<td>${member['그룹'] || '-'}</td>
<td>${member['부서'] || '-'}</td>
<td>${member['소속회사'] || '-'}</td>
<td>${isAdmin ? `<div class="flex gap-1 justify-center"><span class="list-action-btn btn-edit" onclick="openModal('${member._id}')">수정</span><span class="list-action-btn btn-delete" onclick="deleteMember('${member._id}')">삭제</span></div>` : `<span class="list-action-btn btn-edit bg-indigo-50 text-indigo-600 border border-indigo-100" onclick="openModal('${member._id}')">조회</span>`}</td>
</tr>
`;
});
html += '</tbody></table>';
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) {
draggedGroup = { level, name };
event.dataTransfer.effectAllowed = 'move';
}
function handleListGroupDrop(event, targetLevel, targetName) {
event.preventDefault();
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 targetMember = editingMembers.find((member) => (
(member['이름'] || '').toLowerCase().includes(query)
|| levelOrder.some((level) => (member[level] || '').toLowerCase().includes(query))
));
if (!targetMember) {
alert('검색 결과가 없습니다.');
return;
}
const row = document.getElementById(`list-row-${targetMember._id}`);
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) {
draggedIdx = index;
event.dataTransfer.effectAllowed = 'move';
event.target.classList.add('dragging');
}
function handleListDrop(event, targetIdx) {
event.preventDefault();
if (draggedIdx === null || draggedIdx === targetIdx) {
return;
}
const moved = editingMembers.splice(draggedIdx, 1)[0];
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];
for (let index = 0; index <= targetLevelIndex; index += 1) {
moved[levelOrder[index]] = targetMember ? targetMember[levelOrder[index]] : targetName;
}
for (let index = targetLevelIndex + 1; index < levelOrder.length; index += 1) {
moved[levelOrder[index]] = '';
}
rebuildMemberPath(moved);
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];
levelOrder.forEach((level) => {
moved[level] = target[level];
});
rebuildMemberPath(moved);
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();
}
});