From 33f157cb088112d60faa645915f11dfa6b7505bb Mon Sep 17 00:00:00 2001 From: hyunho Date: Mon, 30 Mar 2026 09:46:48 +0900 Subject: [PATCH] feat: add member history list snapshot and compare views --- backend/app/main.py | 145 +++++++++++++++++-- legacy/static/organization.css | 126 ++++++++++++++++ legacy/static/organization.js | 257 ++++++++++++++++++++++++++++----- 3 files changed, 481 insertions(+), 47 deletions(-) diff --git a/backend/app/main.py b/backend/app/main.py index 7b48295..eafd170 100755 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -620,9 +620,9 @@ def sync_seat_assignment_versions(cur, member_ids: list[int], change_reason: str def fetch_members_as_of(cur, as_of: datetime) -> list[dict[str, object]]: cur.execute( """ - SELECT m.id, + SELECT mv.member_id AS id, mv.name, - m.employee_id, + COALESCE(m.employee_id, '') AS employee_id, mv.company, mv.rank, mv.role, @@ -637,25 +637,137 @@ def fetch_members_as_of(cur, as_of: datetime) -> list[dict[str, object]]: mv.email, COALESCE(sav.seat_label, '') AS seat_label, mv.photo_url, - m.sort_order, + COALESCE(m.sort_order, 2147483647) AS sort_order, mv.created_at, mv.valid_from AS updated_at - FROM members m - JOIN member_versions mv - ON mv.member_id = m.id - AND mv.valid_from <= %s - AND (mv.valid_to IS NULL OR mv.valid_to > %s) + FROM member_versions mv + LEFT JOIN members m + ON m.id = mv.member_id LEFT JOIN seat_assignment_versions sav - ON sav.member_id = m.id + ON sav.member_id = mv.member_id AND sav.valid_from <= %s AND (sav.valid_to IS NULL OR sav.valid_to > %s) - ORDER BY m.sort_order ASC, m.id ASC + WHERE mv.valid_from <= %s + AND (mv.valid_to IS NULL OR mv.valid_to > %s) + ORDER BY COALESCE(m.sort_order, 2147483647) ASC, mv.member_id ASC """, (as_of, as_of, as_of, as_of), ) return cur.fetchall() +def build_member_compare_items(from_items: list[dict[str, object]], to_items: list[dict[str, object]]) -> list[dict[str, object]]: + tracked_fields = ( + ("company", "소속회사", "기본"), + ("rank", "직급", "기본"), + ("role", "직책", "기본"), + ("department", "부서", "조직"), + ("grp", "그룹", "조직"), + ("division", "디비전", "조직"), + ("team", "팀", "조직"), + ("cell", "셀", "조직"), + ("work_status", "근무상태", "기본"), + ("work_time", "근무시간", "기본"), + ("phone", "전화번호", "기본"), + ("email", "이메일", "기본"), + ) + + def build_summary(item: dict[str, object] | None) -> list[str]: + if not item: + return [] + summary_fields = ( + ("rank", "직급"), + ("role", "직책"), + ("department", "부서"), + ("grp", "그룹"), + ("division", "디비전"), + ("team", "팀"), + ("cell", "셀"), + ) + lines: list[str] = [] + for field, label in summary_fields: + value = str(item.get(field) or "").strip() + if value: + lines.append(f"{label}: {value}") + return lines + + from_map = {int(item["id"]): item for item in from_items} + to_map = {int(item["id"]): item for item in to_items} + all_ids = sorted(set(from_map) | set(to_map)) + items: list[dict[str, object]] = [] + + for member_id in all_ids: + before = from_map.get(member_id) + after = to_map.get(member_id) + if before is None and after is not None: + items.append( + { + "member_id": member_id, + "name": str(after.get("name") or "-"), + "status": "added", + "status_label": "신규", + "categories": ["신규"], + "changes": [], + "before_lines": [], + "after_lines": build_summary(after), + } + ) + continue + if before is not None and after is None: + items.append( + { + "member_id": member_id, + "name": str(before.get("name") or "-"), + "status": "removed", + "status_label": "삭제", + "categories": ["삭제"], + "changes": [], + "before_lines": build_summary(before), + "after_lines": [], + } + ) + continue + if before is None or after is None: + continue + + changes: list[dict[str, str]] = [] + categories: set[str] = set() + for field, label, category in tracked_fields: + before_value = str(before.get(field) or "").strip() + after_value = str(after.get(field) or "").strip() + if before_value == after_value: + continue + changes.append( + { + "field": field, + "label": label, + "before": before_value, + "after": after_value, + } + ) + categories.add(category) + + if not changes: + continue + + items.append( + { + "member_id": member_id, + "name": str(after.get("name") or before.get("name") or "-"), + "status": "updated", + "status_label": "변경", + "categories": sorted(categories), + "changes": changes, + "before_lines": [f"{change['label']}: {change['before'] or '-'}" for change in changes], + "after_lines": [f"{change['label']}: {change['after'] or '-'}" for change in changes], + } + ) + + order_map = {"added": 0, "updated": 1, "removed": 2} + items.sort(key=lambda item: (order_map.get(str(item.get("status") or ""), 9), str(item.get("name") or ""), int(item.get("member_id") or 0))) + return items + + def serialize_seat_map_payload(payload: SeatMapPayload) -> tuple[object, ...]: return ( payload.name.strip(), @@ -3959,6 +4071,19 @@ def list_members(as_of: str | None = None) -> dict[str, list[dict[str, object]]] return {"items": fetch_members_as_of(cur, parsed_as_of)} +@app.get("/api/history/members/compare") +def compare_members_history(from_date: str, to_date: str) -> dict[str, list[dict[str, object]]]: + parsed_from = parse_as_of(from_date) + parsed_to = parse_as_of(to_date) + if parsed_from is None or parsed_to is None: + raise HTTPException(status_code=400, detail="from_date and to_date are required.") + with get_conn() as conn: + with conn.cursor() as cur: + from_items = fetch_members_as_of(cur, parsed_from) + to_items = fetch_members_as_of(cur, parsed_to) + return {"items": build_member_compare_items(from_items, to_items)} + + @app.post("/api/members") def create_member(payload: MemberPayload) -> dict[str, object]: with get_conn() as conn: diff --git a/legacy/static/organization.css b/legacy/static/organization.css index 10a7d5d..c88e349 100644 --- a/legacy/static/organization.css +++ b/legacy/static/organization.css @@ -802,6 +802,132 @@ body { color: #ef4444; } +.list-toolbar { + display: flex; + flex-direction: column; + gap: 10px; + margin-bottom: 16px; + padding: 4px; +} + +.list-toolbar-row { + display: flex; + gap: 10px; + align-items: center; + flex-wrap: wrap; +} + +.list-mode-btn { + border: 1px solid #c7d2fe; + background: #eef2ff; + color: #4338ca; + padding: 10px 14px; + border-radius: 12px; + font-size: 12px; + font-weight: 800; + cursor: pointer; +} + +.list-date-group { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +.list-date-input { + border: 1px solid #cbd5e1; + background: #fff; + color: #0f172a; + padding: 9px 12px; + border-radius: 12px; + font-size: 12px; + font-weight: 700; +} + +.list-date-separator { + color: #64748b; + font-size: 12px; + font-weight: 800; +} + +.list-view-status { + color: #64748b; + font-size: 12px; + font-weight: 700; + padding: 2px 4px 0; +} + +.col-compare-status { + width: 82px; +} + +.col-compare-category { + width: 120px; +} + +.list-empty-cell { + padding: 24px 16px !important; + color: #64748b; + font-weight: 700; + text-align: center !important; +} + +.list-compare-cell { + text-align: left !important; + vertical-align: top !important; + line-height: 1.6; +} + +.list-compare-line + .list-compare-line { + margin-top: 3px; +} + +.list-compare-chip-group { + display: flex; + gap: 6px; + justify-content: center; + flex-wrap: wrap; +} + +.list-compare-chip { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 4px 8px; + border-radius: 999px; + background: #e2e8f0; + color: #334155; + font-size: 10px; + font-weight: 800; +} + +.list-compare-status { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 54px; + padding: 4px 8px; + border-radius: 999px; + font-size: 10px; + font-weight: 900; +} + +.list-compare-status-added { + background: #dcfce7; + color: #166534; +} + +.list-compare-status-updated { + background: #dbeafe; + color: #1d4ed8; +} + +.list-compare-status-removed { + background: #fee2e2; + color: #b91c1c; +} + .fab-container { position: fixed; bottom: 30px; diff --git a/legacy/static/organization.js b/legacy/static/organization.js index 893168b..bcc5d16 100644 --- a/legacy/static/organization.js +++ b/legacy/static/organization.js @@ -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 = ` -
- - +
+
+ +
+ + +
+
+ + ~ + + +
+
+
+ + +
+
`; - 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 = `
@@ -1358,19 +1417,135 @@ function openListViewModal(event) {
`; - } else { - footer.innerHTML = '
'; - } - modal.style.display = 'flex'; -} - -async function applyListViewChanges() { - if (!confirm('리스트의 변경 사항을 메인 화면에 반영하시겠습니까?')) { return; } - await syncMembers(editingMembers); - isListMode = false; - closeModal(); + footer.innerHTML = '
'; +} + +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 = ` + + + + + + + + + + + + `; + + if (!rows.length) { + html += ''; + } else { + rows.forEach((item) => { + const categories = (item.categories || []).map((category) => `${escapeHtml(category)}`).join(''); + const beforeLines = (item.before_lines || []).map((line) => `
${escapeHtml(line)}
`).join('') || '-'; + const afterLines = (item.after_lines || []).map((line) => `
${escapeHtml(line)}
`).join('') || '-'; + html += ` + + + + + + + + `; + }); + } + + html += '
이름상태변경유형이전현재
선택한 기간 사이의 구성원 변경 내역이 없습니다.
${escapeHtml(item.name || '-')}${escapeHtml(item.status_label || '-')}
${categories || '-'}
${beforeLines}${afterLines}
'; + 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 = `${isAdmin ? '' : ''}`; + const sourceMembers = getRenderableListMembers(); + const editable = isAdmin && listViewState.mode === 'current'; + const inspectable = !editable && listViewState.mode === 'current'; + const groupColumnCount = editable ? 11 : 10; + let html = `
순서이름직급직책디비전그룹부서소속${isAdmin ? '관리' : '조회'}
${editable ? '' : ''}`; 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 += ``; + const dragAttr = editable ? `draggable="true" ondragstart="handleListGroupDragStart(event, '${jsString(level)}', '${jsString(value)}')" ondragover="event.preventDefault()" ondrop="handleListGroupDrop(event, '${jsString(level)}', '${jsString(value)}')"` : ''; + html += ``; 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 + ? `
수정삭제
` + : inspectable + ? `조회` + : '-'; html += ` - ${isAdmin ? '' : ''} - - - - - - - - - - + ${editable ? '' : ''} + + + + + + + + + + `; }); @@ -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');
순서이름직급직책디비전그룹부서소속${editable ? '관리' : '조회'}
${value}
${escapeHtml(value)}
${member['이름']}${member['직급'] || '-'}${member['근무상태'] === '휴직' ? '휴직' : (member['직책'] || '-')}${member['셀'] || '-'}${member['팀'] || '-'}${member['디비전'] || '-'}${member['그룹'] || '-'}${member['부서'] || '-'}${member['소속회사'] || '-'}${isAdmin ? `
수정삭제
` : `조회`}
${escapeHtml(member['이름'] || '-')}${escapeHtml(member['직급'] || '-')}${member['근무상태'] === '휴직' ? '휴직' : escapeHtml(member['직책'] || '-')}${escapeHtml(member['셀'] || '-')}${escapeHtml(member['팀'] || '-')}${escapeHtml(member['디비전'] || '-')}${escapeHtml(member['그룹'] || '-')}${escapeHtml(member['부서'] || '-')}${escapeHtml(member['소속회사'] || '-')}${actionCell}