feat: add member history list snapshot and compare views

This commit is contained in:
hyunho
2026-03-30 09:46:48 +09:00
parent b735a4cdd1
commit 33f157cb08
3 changed files with 481 additions and 47 deletions

View File

@@ -8,6 +8,14 @@ let emptyStateMessage = '서버에 조직 데이터가 없습니다. 상단의
let photoPreviewObjectUrl = null;
let seatMapLayoutCache = null;
let activeAsOfDate = '';
const listViewState = {
mode: 'current',
snapshotDate: '',
compareFromDate: '',
compareToDate: '',
snapshotMembers: [],
compareItems: [],
};
const seatMapOfficeKeys = ['technical-development-center', 'hanmac-building-6f', 'hanmac-building-7f'];
const levelOrder = ['부서', '그룹', '디비전', '팀', '셀'];
@@ -126,6 +134,14 @@ function withAsOf(url) {
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);
@@ -1326,6 +1342,14 @@ function openListViewModal(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 = '인원 명단';
@@ -1335,16 +1359,51 @@ function openListViewModal(event) {
isListMode = true;
editingMembers = cloneMembers(members);
fieldsArea.innerHTML = `
<div class="mb-4 flex gap-2 p-1">
<input type="text" id="list-search-input" placeholder="이름 또는 부서/팀 검색 (Enter 시 이동)" class="flex-1 bg-slate-50 border-2 border-slate-100 p-3 rounded-xl text-sm outline-none font-bold focus:border-indigo-400 transition-all" onkeydown="if(event.key==='Enter') handleListSearch(this.value)">
<button onclick="handleListSearch(document.getElementById('list-search-input').value)" class="bg-indigo-600 text-white px-5 rounded-xl font-bold text-sm">검색</button>
<div 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>
`;
renderListViewTable();
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 (isAdmin) {
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">
@@ -1358,19 +1417,135 @@ function openListViewModal(event) {
</div>
</div>
`;
} else {
footer.innerHTML = '<div class="flex gap-2 w-full justify-end items-center"><button onclick="closeModal()" class="bg-indigo-600 text-white px-10 py-2.5 rounded-lg text-xs font-bold shadow-lg shadow-indigo-200">닫기</button></div>';
}
modal.style.display = 'flex';
}
async function applyListViewChanges() {
if (!confirm('리스트의 변경 사항을 메인 화면에 반영하시겠습니까?')) {
return;
}
await syncMembers(editingMembers);
isListMode = false;
closeModal();
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() {
@@ -1379,13 +1554,17 @@ function renderListViewTable() {
return;
}
let html = `<table class="list-table"><thead><tr>${isAdmin ? '<th width="40">순서</th>' : ''}<th class="col-name">이름</th><th class="col-rank">직급</th><th class="col-pos">직책</th><th class="col-unit-sm">셀</th><th class="col-unit-sm">팀</th><th class="col-unit-lg">디비전</th><th class="col-unit-lg">그룹</th><th class="col-unit-lg">부서</th><th class="col-corp">소속</th><th class="col-action">${isAdmin ? '관리' : '조회'}</th></tr></thead><tbody id="list-body">`;
const 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] = '';
});
editingMembers.forEach((member, index) => {
sourceMembers.forEach((member, index) => {
let isAnyParentCollapsed = false;
levelOrder.forEach((level, depth) => {
const value = (member[level] || '').trim();
@@ -1399,8 +1578,8 @@ function renderListViewTable() {
}
if (value !== lastValues[level]) {
const isCollapsed = collapsedUnits.has(key);
const dragAttr = isAdmin ? `draggable="true" ondragstart="handleListGroupDragStart(event, '${jsString(level)}', '${jsString(value)}')" ondragover="event.preventDefault()" ondrop="handleListGroupDrop(event, '${jsString(level)}', '${jsString(value)}')"` : '';
html += `<tr ${dragAttr} class="list-header-row lvl-${depth} ${isCollapsed ? 'collapsed' : ''} ${isAnyParentCollapsed ? 'hidden-row' : ''}"><td colspan="${(isAdmin ? 10 : 9) + 1}" onclick="toggleUnitCollapse('${jsString(level)}', '${jsString(value)}')" style="padding-left: 15px !important;"><span class="collapse-icon">▼</span> ${value}</td></tr>`;
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] = '';
@@ -1409,20 +1588,25 @@ function renderListViewTable() {
});
const hidden = levelOrder.some((level) => member[level] && collapsedUnits.has(`${level}_${member[level].trim()}`)) || isAnyParentCollapsed;
const rowDragAttr = isAdmin ? `draggable="true" ondragstart="handleListDragStart(event, ${index})" ondragover="event.preventDefault()" ondrop="handleListDrop(event, ${index})"` : '';
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' : ''}">
${isAdmin ? '<td class="text-slate-300 cursor-move">☰</td>' : ''}
<td class="font-black text-slate-700">${member['이름']}</td>
<td>${member['직급'] || '-'}</td>
<td>${member['근무상태'] === '휴직' ? '<span class="text-red-500 font-black">휴직</span>' : (member['직책'] || '-')}</td>
<td>${member['셀'] || '-'}</td>
<td>${member['팀'] || '-'}</td>
<td>${member['디비전'] || '-'}</td>
<td>${member['그룹'] || '-'}</td>
<td>${member['부서'] || '-'}</td>
<td>${member['소속회사'] || '-'}</td>
<td>${isAdmin ? `<div class="flex gap-1 justify-center"><span class="list-action-btn btn-edit" onclick="openModal('${member._id}')">수정</span><span class="list-action-btn btn-delete" onclick="deleteMember('${member._id}')">삭제</span></div>` : `<span class="list-action-btn btn-edit bg-indigo-50 text-indigo-600 border border-indigo-100" onclick="openModal('${member._id}')">조회</span>`}</td>
${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>
`;
});
@@ -1472,15 +1656,14 @@ function handleListSearch(value) {
return;
}
document.querySelectorAll('.list-search-target').forEach((element) => element.classList.remove('list-search-target'));
const targetMember = editingMembers.find((member) => (
(member['이름'] || '').toLowerCase().includes(query)
|| levelOrder.some((level) => (member[level] || '').toLowerCase().includes(query))
const targetEntry = getListSearchEntries().find((entry) => (
entry.values.some((candidate) => String(candidate || '').toLowerCase().includes(query))
));
if (!targetMember) {
if (!targetEntry) {
alert('검색 결과가 없습니다.');
return;
}
const row = document.getElementById(`list-row-${targetMember._id}`);
const row = document.getElementById(targetEntry.rowId);
if (row) {
row.scrollIntoView({ behavior: 'smooth', block: 'center' });
row.classList.add('list-search-target');