feat: add member history list snapshot and compare views
This commit is contained in:
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user