Files

1915 lines
69 KiB
JavaScript

let members = [];
let isAdmin = false;
let selectedDept = '전체';
let editingMembers = [];
let collapsedUnits = new Set();
let isListMode = 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('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
}
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) => `
<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 = {};
const companyLabelHtml = (company) => `
<span class="stats-company-label">
<span class="stats-company-dot co-${company}"></span>
<span>${company}</span>
</span>
`;
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">${companyLabelHtml(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) {
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 = '<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>';
html += '<button class="fab-sub shadow-xl" data-label="자리배치도" onclick="openSeatMapView(event)">🪑</button>';
if (isAdmin && !isHistoricalSnapshot) {
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;
}
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 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="member-form-label block">상위 위치 선택</label>
<select id="new-unit-parent" class="member-form-select"></select>
</div>
<div class="col-span-2">
<label class="member-form-label block">신규 명칭 입력</label>
<input id="new-unit-name" placeholder="예: 신규개발팀" class="member-form-input">
</div>
`;
updateParentList();
document.getElementById('modal-footer-area').innerHTML = `
<div class="modal-footer-actions">
<button onclick="closeModal()" class="modal-btn modal-btn-cancel">취소</button>
<button onclick="saveNewUnit()" class="modal-btn modal-btn-save">저장</button>
</div>
`;
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="member-form-label block">새로운 ${level} 명칭</label>
<input id="new-org-name" value="${oldName}" class="member-form-input">
</div>
`;
document.getElementById('modal-footer-area').innerHTML = `
<button onclick="deleteOrg('${jsString(level)}', '${jsString(oldName)}')" class="modal-btn modal-btn-delete">삭제</button>
<div class="modal-footer-actions">
<button onclick="closeModal()" class="modal-btn modal-btn-cancel">취소</button>
<button onclick="saveOrgName('${jsString(level)}', '${jsString(oldName)}')" class="modal-btn modal-btn-save">저장</button>
</div>
`;
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
? `<span class="seat-preview-badge">${safeOfficeLabel}</span>`
: '<span class="seat-preview-badge seat-preview-badge-muted">미배치</span>';
const body = assigned
? `
<iframe
id="member-seat-preview-frame"
class="seat-preview-frame"
src="/api/seat-maps/${Number(seatInfo.seatMapId || 0)}/viewer"
title="${safeSeatMapName} 좌석 미리보기"
loading="eager"
referrerpolicy="same-origin"
></iframe>
`
: `
<div class="seat-preview-placeholder">
<span class="seat-preview-placeholder-icon">⌖</span>
<span>현재 공석 또는 미배치 상태입니다.</span>
</div>
`;
return `
<div class="seat-preview-card${assigned ? ' is-assigned' : ''}">
<div class="seat-preview-head">
<div>
<strong>재석위치</strong>
<p>${assigned ? '현재 배치된 사무실과 좌석 위치를 강조해서 표시합니다.' : '현재 자리배치도에서 배치된 좌석이 없습니다.'}</p>
</div>
${badge}
</div>
<div class="seat-preview-canvas">
${body}
</div>
</div>
`;
}
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 = `
<div class="member-detail-top-row">
<div class="relative w-32 h-32 rounded-full overflow-hidden border-4 shadow-lg" style="border-color: var(--color-surface-strong);">
<img src="${member['사진'] || 'https://via.placeholder.com/120?text=Profile'}" class="w-full h-full object-cover">
</div>
<div class="member-detail-summary">
<div>
<h2 class="text-2xl font-black text-slate-800">${member['이름'] || ''}</h2>
<p class="font-bold" style="color: var(--color-header);">${member['직급'] || '-'} / ${member['직책'] || '팀원'}</p>
<p class="text-xs mt-1 font-medium" style="color: var(--color-text-muted);">${(member._path || []).map((path) => path.name).join(' > ')}</p>
</div>
<div class="member-inline-info-grid">
<div class="member-inline-info-card">
<label>전화번호</label>
<strong>${member['전화번호'] || '정보 없음'}</strong>
</div>
<div class="member-inline-info-card">
<label>이메일</label>
<strong>${member['이메일'] || '정보 없음'}</strong>
</div>
</div>
</div>
</div>
<div class="w-full mt-2">
<div id="member-seat-preview">${renderSeatPreviewCard({ assigned: false, seatLabel: member['자리위치'] || '', seatMapName: '자리배치도', slotKey: '' })}</div>
</div>
`;
footer.innerHTML = '<button onclick="closeModal()" class="modal-btn modal-btn-close">닫기</button>';
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 = 'visible';
const sourceValues = isListMode ? editingMembers : members;
let orgFields = '<div id="modal-sec-org" class="hidden grid grid-cols-2 gap-3 modal-form-grid">';
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="member-form-label block">${field}</label>
<select id="sel-${field}" onchange="toggleManualInput('${field}')" class="member-form-select">
<option value="__NEW__" class="member-form-new-option">+ 직접/신규 입력</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 member-form-manual">
<input id="input-${field}" placeholder="직접 입력" class="member-form-input">
</div>
</div>
`;
});
const isFlexible = member['근무시간'] === '유연근무제';
orgFields += `
<div class="col-span-1">
<label class="member-form-label block">근무 상태</label>
<select id="m-status" class="member-form-select">
<option value="근무" ${member['근무상태'] !== '휴직' && member['근무상태'] !== '퇴직' ? 'selected' : ''}>근무</option>
<option value="휴직" ${member['근무상태'] === '휴직' ? 'selected' : ''}>휴직</option>
<option value="퇴직" ${member['근무상태'] === '퇴직' ? 'selected' : ''}>퇴직</option>
</select>
</div>
<div class="col-span-1">
<label class="member-form-label block">근무 시간</label>
<select id="m-worktime" onchange="toggleFlexibleTime(this.value)" class="member-form-select">
<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="member-form-time">
<input type="time" id="m-work-end" value="${member['유연근무_종료'] || '18:00'}" class="member-form-time">
</div>
</div>
</div>`;
fieldsArea.innerHTML = `
<div class="member-modal-tabs">
<button id="modal-tab-basic" onclick="switchModalTab('basic')" class="member-modal-tab is-active">기본 정보</button>
<button id="modal-tab-org" onclick="switchModalTab('org')" class="member-modal-tab">조직 및 근무</button>
</div>
<div id="modal-sec-basic" class="modal-form-grid member-basic-editor">
<input type="hidden" id="m-id" value="${id || ''}">
<input type="hidden" id="m-photo-hidden" value="${member['사진'] || ''}">
<input type="hidden" id="m-seat-hidden" value="${member['자리위치'] || ''}">
<div class="member-basic-split">
<div class="member-basic-left">
<div class="member-photo-panel">
<p class="member-modal-panel-title">기본 정보</p>
<div class="member-photo-upload-card member-photo-upload-card-inline">
<div class="member-photo-card-title">프로필 사진</div>
<div class="member-photo-preview-wrap">
<img id="m-photo-preview" src="${member['사진'] || getPhotoPlaceholder(member['이름'] || '')}" alt="프로필 미리보기" class="member-photo-preview">
</div>
<div class="member-photo-upload-controls">
<label class="member-photo-file-label" for="m-photo-file">
<input id="m-photo-file" type="file" accept="image/png,image/jpeg,image/webp,image/gif" onchange="handlePhotoFileChange(event)">
<span>사진 파일 선택</span>
</label>
<strong id="m-photo-file-name" class="member-photo-file-name">선택된 파일 없음</strong>
</div>
</div>
</div>
<div class="member-basic-fields member-modal-panel">
<p class="member-modal-panel-title">기본 정보</p>
<div class="member-basic-field">
<label class="member-form-label block">이름 (필수)</label>
<input id="m-name" value="${member['이름'] || ''}" oninput="syncPhotoPreviewFromUrl()" class="member-form-input">
</div>
<div class="member-basic-field">
<label class="member-form-label block">사번</label>
<input id="m-employee-id" value="${member['사번'] || ''}" class="member-form-input">
</div>
<div class="member-basic-field">
<label class="member-form-label block">전화번호</label>
<input id="m-phone" value="${member['전화번호'] || ''}" class="member-form-input">
</div>
<div class="member-basic-field">
<label class="member-form-label block">이메일</label>
<input id="m-email" value="${member['이메일'] || ''}" class="member-form-input">
</div>
</div>
</div>
<div class="member-basic-right">
<p class="member-modal-panel-title" style="padding:16px 16px 0;">조직 및 근무</p>
<div class="member-seat-field member-seat-field-compact">
<div id="member-seat-preview">${renderSeatPreviewCard({ assigned: false, seatLabel: member['자리위치'] || '', seatMapName: '자리배치도', slotKey: '' })}</div>
</div>
</div>
</div>
</div>
<div class="member-modal-panel">${orgFields}</div>
`;
resetPhotoPreviewObjectUrl();
const deleteBtn = id ? `<button onclick="deleteMember('${id}')" class="modal-btn modal-btn-delete">삭제</button>` : '';
footer.innerHTML = `
${deleteBtn}
<div class="modal-footer-actions">
<button onclick="closeModal()" class="modal-btn modal-btn-cancel">취소</button>
<button onclick="saveMember()" class="modal-btn modal-btn-save">저장</button>
</div>
`;
modal.style.display = 'flex';
if (id) {
hydrateMemberSeatPreview(member);
}
}
function closeModal() {
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.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;
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);
}
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 defaultDate = getDefaultHistoryDate();
listViewState.mode = 'current';
listViewState.snapshotDate = defaultDate;
listViewState.compareFromDate = defaultDate;
listViewState.compareToDate = defaultDate;
listViewState.snapshotMembers = [];
listViewState.compareItems = [];
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="list-toolbar">
<div class="list-toolbar-row">
<div class="list-toolbar-group">
<button type="button" onclick="showCurrentListView()" class="list-mode-btn">현재 명단</button>
</div>
<div class="list-toolbar-divider" aria-hidden="true"></div>
<div class="list-toolbar-group list-date-group">
<input type="date" id="list-snapshot-date" value="${escapeHtml(defaultDate)}" class="list-date-input">
<button type="button" onclick="loadSnapshotListView()" class="list-mode-btn">기준일 조회</button>
</div>
<div class="list-toolbar-divider" aria-hidden="true"></div>
<div class="list-toolbar-group list-date-group">
<input type="date" id="list-compare-from" value="${escapeHtml(defaultDate)}" class="list-date-input">
<span class="list-date-separator">~</span>
<input type="date" id="list-compare-to" value="${escapeHtml(defaultDate)}" class="list-date-input">
<button type="button" onclick="loadCompareListView()" class="list-mode-btn">변경 비교</button>
</div>
</div>
<div class="list-toolbar-row">
<input type="text" id="list-search-input" placeholder="이름 또는 부서/팀 검색 (Enter 시 이동)" class="flex-1 bg-[#f6eddd] border-2 border-[#e0d0b4] p-3 rounded-xl text-sm outline-none font-bold text-[#1f2f25] focus:border-[#d68a3a] transition-all" onkeydown="if(event.key==='Enter') handleListSearch(this.value)">
<button type="button" onclick="handleListSearch(document.getElementById('list-search-input').value)" class="bg-[#214634] text-white px-5 rounded-xl font-bold text-sm">검색</button>
</div>
<div id="list-view-status" class="list-view-status"></div>
</div>
<div id="list-table-container" class="overflow-y-auto flex-1 border rounded-xl"></div>
`;
renderListViewModalContent();
modal.style.display = 'flex';
}
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 = `
<div class="flex gap-2 w-full justify-between items-center">
<div class="flex gap-2">
<button onclick="openAddModal(event)" class="bg-[#f6eddd] text-[#214634] px-4 py-2 rounded-lg text-xs font-bold border border-[#e0d0b4]">+ 구성원 추가</button>
<button onclick="openUnitAddModal(event)" class="bg-[#f6eddd] text-[#214634] px-4 py-2 rounded-lg text-xs font-bold border border-[#e0d0b4]">+ 조직 추가</button>
</div>
<div class="flex gap-2 items-center">
<p class="text-[10px] text-[#8b8a77] font-bold mr-4">항목을 드래그하여 순서를 바꿀 수 있습니다.</p>
<button onclick="closeModal()" class="bg-[#efe4d0] text-[#5b665a] px-6 py-2 rounded-lg text-xs font-bold">취소</button>
<button onclick="applyListViewChanges()" class="bg-[#214634] text-white px-8 py-2 rounded-lg text-xs font-bold shadow-lg shadow-[#d6c1a3]">반영하기</button>
</div>
</div>
`;
return;
}
footer.innerHTML = '<div class="flex gap-2 w-full justify-end items-center"><button onclick="closeModal()" class="bg-[#214634] text-white px-10 py-2.5 rounded-lg text-xs font-bold shadow-lg shadow-[#d6c1a3]">닫기</button></div>';
}
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 = `
<table class="list-table list-compare-table">
<thead>
<tr>
<th class="col-name">이름</th>
<th class="col-compare-status">상태</th>
<th class="col-compare-date">변경일시</th>
<th class="col-compare-category">변경유형</th>
<th>이전</th>
<th>현재</th>
</tr>
</thead>
<tbody>
`;
if (!rows.length) {
html += '<tr><td colspan="6" class="list-empty-cell">선택한 기간 사이의 구성원 변경 내역이 없습니다.</td></tr>';
} else {
rows.forEach((item) => {
const categories = (item.categories || []).map((category) => `<span class="list-compare-chip">${escapeHtml(category)}</span>`).join('');
const beforeLines = (item.before_lines || []).map((line) => `<div class="list-compare-line">${escapeHtml(line)}</div>`).join('') || '<span class="text-slate-300">-</span>';
const afterLines = (item.after_lines || []).map((line) => `<div class="list-compare-line">${escapeHtml(line)}</div>`).join('') || '<span class="text-slate-300">-</span>';
html += `
<tr id="list-compare-row-${item.member_id}">
<td class="font-black text-slate-700">${escapeHtml(item.name || '-')}</td>
<td><span class="list-compare-status list-compare-status-${escapeHtml(item.status || 'updated')}">${escapeHtml(item.status_label || '-')}</span></td>
<td>${escapeHtml(formatCompareChangedAt(item.changed_at))}</td>
<td><div class="list-compare-chip-group">${categories || '<span class="text-slate-300">-</span>'}</div></td>
<td class="list-compare-cell">${beforeLines}</td>
<td class="list-compare-cell">${afterLines}</td>
</tr>
`;
});
}
html += '</tbody></table>';
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 = `<table class="list-table"><thead><tr>${editable ? '<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">${editable ? '관리' : '조회'}</th></tr></thead><tbody id="list-body">`;
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 += `<tr ${dragAttr} class="list-header-row lvl-${depth} ${isCollapsed ? 'collapsed' : ''} ${isAnyParentCollapsed ? 'hidden-row' : ''}"><td colspan="${groupColumnCount}" onclick="toggleUnitCollapse('${jsString(level)}', '${jsString(value)}')" style="padding-left: 15px !important;"><span class="collapse-icon">▼</span> ${escapeHtml(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 = editable ? `draggable="true" ondragstart="handleListDragStart(event, ${index})" ondragover="event.preventDefault()" ondrop="handleListDrop(event, ${index})"` : '';
const actionCell = editable
? `<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>`
: inspectable
? `<span class="list-action-btn btn-edit bg-indigo-50 text-indigo-600 border border-indigo-100" onclick="openModal('${member._id}')">조회</span>`
: '<span class="text-slate-300">-</span>';
html += `
<tr id="list-row-${member._id}" ${rowDragAttr} class="${hidden ? 'hidden-row' : ''}">
${editable ? '<td class="text-slate-300 cursor-move">☰</td>' : ''}
<td class="font-black text-slate-700">${escapeHtml(member['이름'] || '-')}</td>
<td>${escapeHtml(member['직급'] || '-')}</td>
<td>${member['근무상태'] === '휴직' ? '<span class="text-red-500 font-black">휴직</span>' : escapeHtml(member['직책'] || '-')}</td>
<td>${escapeHtml(member['셀'] || '-')}</td>
<td>${escapeHtml(member['팀'] || '-')}</td>
<td>${escapeHtml(member['디비전'] || '-')}</td>
<td>${escapeHtml(member['그룹'] || '-')}</td>
<td>${escapeHtml(member['부서'] || '-')}</td>
<td>${escapeHtml(member['소속회사'] || '-')}</td>
<td>${actionCell}</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 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) {
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();
}
});