1877 lines
69 KiB
JavaScript
1877 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 getPhotoPlaceholder(name = '') {
|
|
return `https://via.placeholder.com/160?text=${encodeURIComponent(name || 'Profile')}`;
|
|
}
|
|
|
|
function escapeHtml(value) {
|
|
return String(value ?? '')
|
|
.replaceAll('&', '&')
|
|
.replaceAll('<', '<')
|
|
.replaceAll('>', '>')
|
|
.replaceAll('"', '"')
|
|
.replaceAll("'", ''');
|
|
}
|
|
|
|
function resetPhotoPreviewObjectUrl() {
|
|
if (photoPreviewObjectUrl) {
|
|
URL.revokeObjectURL(photoPreviewObjectUrl);
|
|
photoPreviewObjectUrl = null;
|
|
}
|
|
}
|
|
|
|
function toLegacyMember(item) {
|
|
return rebuildMemberPath({
|
|
_id: String(item.id),
|
|
id: item.id,
|
|
이름: item.name || '',
|
|
사번: item.employee_id || '',
|
|
소속회사: item.company || '',
|
|
직급: item.rank || '',
|
|
직책: item.role || '',
|
|
부서: item.department || '',
|
|
그룹: item.grp || '',
|
|
디비전: item.division || '',
|
|
팀: item.team || '',
|
|
셀: item.cell || '',
|
|
근무상태: item.work_status || '',
|
|
근무시간: item.work_time || '',
|
|
전화번호: item.phone || '',
|
|
이메일: item.email || '',
|
|
자리위치: item.seat_label || '',
|
|
사진: item.photo_url || '',
|
|
sort_order: item.sort_order ?? 0,
|
|
});
|
|
}
|
|
|
|
function toApiMember(member, sortOrder) {
|
|
return {
|
|
name: member['이름'] || '',
|
|
employee_id: member['사번'] || '',
|
|
company: member['소속회사'] || '',
|
|
rank: member['직급'] || '',
|
|
role: member['직책'] || '',
|
|
department: member['부서'] || '',
|
|
grp: member['그룹'] || '',
|
|
division: member['디비전'] || '',
|
|
team: member['팀'] || '',
|
|
cell: member['셀'] || '',
|
|
work_status: member['근무상태'] || '',
|
|
work_time: member['근무시간'] || '',
|
|
phone: member['전화번호'] || '',
|
|
email: member['이메일'] || '',
|
|
seat_label: member['자리위치'] || '',
|
|
photo_url: member['사진'] || '',
|
|
sort_order: sortOrder,
|
|
};
|
|
}
|
|
|
|
async function apiFetch(url, options = {}) {
|
|
const config = { ...options };
|
|
config.headers = { ...(options.headers || {}) };
|
|
if (config.body && !(config.body instanceof FormData) && !config.headers['Content-Type']) {
|
|
config.headers['Content-Type'] = 'application/json';
|
|
}
|
|
|
|
const response = await fetch(url, config);
|
|
const contentType = response.headers.get('content-type') || '';
|
|
const payload = contentType.includes('application/json') ? await response.json() : null;
|
|
if (!response.ok) {
|
|
const detail = payload?.detail || '요청 처리에 실패했습니다.';
|
|
throw new Error(detail);
|
|
}
|
|
return payload;
|
|
}
|
|
|
|
function withAsOf(url) {
|
|
if (!activeAsOfDate) {
|
|
return url;
|
|
}
|
|
const separator = url.includes('?') ? '&' : '?';
|
|
return `${url}${separator}as_of=${encodeURIComponent(activeAsOfDate)}`;
|
|
}
|
|
|
|
function getDefaultHistoryDate() {
|
|
if (activeAsOfDate) {
|
|
return activeAsOfDate;
|
|
}
|
|
const now = new Date();
|
|
return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`;
|
|
}
|
|
|
|
async function uploadProfilePhoto(file, memberName) {
|
|
const formData = new FormData();
|
|
formData.append('file', file);
|
|
formData.append('member_name', memberName || '');
|
|
const payload = await apiFetch('/api/uploads/profile-photo', {
|
|
method: 'POST',
|
|
body: formData,
|
|
});
|
|
return payload.url || '';
|
|
}
|
|
|
|
function setMembers(items) {
|
|
members = 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="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 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 safeLabel = escapeHtml(seatInfo?.seatLabel || '');
|
|
const safeSeatMapName = escapeHtml(seatInfo?.seatMapName || '자리배치도');
|
|
const safeSlotKey = escapeHtml(seatInfo?.slotKey || '');
|
|
const badge = assigned
|
|
? `<span class="seat-preview-badge">${safeLabel || '배치완료'}</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">
|
|
<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
|
|
? '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="member-detail-top-row">
|
|
<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="member-detail-summary">
|
|
<div>
|
|
<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="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="w-full bg-slate-800 text-white py-4 rounded-xl font-bold text-sm shadow-lg">닫기</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="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-3 modal-form-grid">
|
|
<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="col-span-2 member-edit-layout">
|
|
<div class="member-edit-left-pane">
|
|
<div class="member-edit-profile-card">
|
|
<label class="text-[11px] font-black text-slate-600 block">프로필 사진</label>
|
|
<div class="member-photo-upload-card member-photo-upload-card-compact">
|
|
<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 class="member-name-field member-name-field-compact">
|
|
<label class="text-[11px] font-black text-slate-600 block">이름 (필수)</label>
|
|
<input id="m-name" value="${member['이름'] || ''}" oninput="syncPhotoPreviewFromUrl()" class="w-full bg-slate-50 p-3 rounded-xl border font-bold text-sm outline-none">
|
|
</div>
|
|
<div class="member-inline-info-grid member-inline-info-grid-stacked">
|
|
<div class="member-inline-info-card member-inline-info-card-full">
|
|
<label>사번</label>
|
|
<input id="m-employee-id" value="${member['사번'] || ''}" class="w-full bg-white p-3 rounded-xl border font-bold text-sm outline-none">
|
|
</div>
|
|
<div class="member-inline-info-card member-inline-info-card-full">
|
|
<label>전화번호</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="member-inline-info-card member-inline-info-card-full">
|
|
<label>이메일</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>
|
|
</div>
|
|
</div>
|
|
<div class="member-edit-right-pane">
|
|
<div class="member-seat-field member-seat-field-emphasis">
|
|
<div id="member-seat-preview">${renderSeatPreviewCard({ assigned: false, seatLabel: member['자리위치'] || '', seatMapName: '자리배치도', slotKey: '' })}</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
${orgFields}
|
|
`;
|
|
|
|
resetPhotoPreviewObjectUrl();
|
|
|
|
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';
|
|
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">
|
|
<button type="button" onclick="showCurrentListView()" class="list-mode-btn">현재 명단</button>
|
|
<div class="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-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-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 type="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-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-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>
|
|
`;
|
|
return;
|
|
}
|
|
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>';
|
|
}
|
|
|
|
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 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-category">변경유형</th>
|
|
<th>이전</th>
|
|
<th>현재</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
`;
|
|
|
|
if (!rows.length) {
|
|
html += '<tr><td colspan="5" 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><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 = (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();
|
|
}
|
|
});
|