Fix organization member editing and drag sync

This commit is contained in:
hyunho
2026-04-02 10:38:47 +09:00
parent a4480c3435
commit 8125193378
6 changed files with 155 additions and 66 deletions

View File

@@ -8,8 +8,8 @@
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Pretendard:wght@400;600;700;900&display=swap" rel="stylesheet" />
<script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="/legacy/static/common.css?v=20260331-01" />
<link rel="stylesheet" href="/legacy/static/organization.css?v=20260331-01" />
<link rel="stylesheet" href="/legacy/static/common.css?v=20260402-02" />
<link rel="stylesheet" href="/legacy/static/organization.css?v=20260402-02" />
</head>
<body>
<input type="file" id="upload-excel" class="hidden" accept=".xlsx, .csv" />
@@ -60,6 +60,6 @@
</div>
</div>
<script src="/legacy/static/organization.js?v=20260331-01"></script>
<script src="/legacy/static/organization.js?v=20260402-02"></script>
</body>
</html>

View File

@@ -3,7 +3,7 @@ from __future__ import annotations
from datetime import datetime
from typing import Callable
from fastapi import FastAPI, File, HTTPException, UploadFile
from fastapi import Body, FastAPI, File, HTTPException, UploadFile
def register_member_routes(
@@ -19,6 +19,7 @@ def register_member_routes(
serialize_member_payload: Callable[[object, int], tuple[object, ...]],
sync_auth_users_from_members: Callable[[object], None],
create_history_revision: Callable[[object, str, str], int],
fetch_history_revision_created_at: Callable[[object, int], datetime],
sync_member_versions: Callable[[object, list[int], str, int], None],
sync_seat_assignment_versions: Callable[[object, list[int], str, int], None],
replace_members: Callable[[list[object]], list[dict[str, object]]],
@@ -46,7 +47,8 @@ def register_member_routes(
return {"items": build_member_compare_items(from_items, to_items)}
@app.post("/api/members")
def create_member(payload: member_payload_cls) -> dict[str, object]:
def create_member(payload: dict = Body(...)) -> dict[str, object]:
payload = member_payload_cls.model_validate(payload)
with get_conn() as conn:
with conn.cursor() as cur:
cur.execute("SELECT COALESCE(MAX(sort_order), -1) + 1 AS next_order FROM members")
@@ -67,16 +69,19 @@ def register_member_routes(
member = cur.fetchone()
sync_auth_users_from_members(cur)
revision_no = create_history_revision(cur, "member-create", f"Member created id={int(member['id'])}")
sync_member_versions(cur, [int(member["id"])], "member-create", revision_no)
revision_created_at = fetch_history_revision_created_at(cur, revision_no)
sync_member_versions(cur, [int(member["id"])], "member-create", revision_no, revision_created_at)
conn.commit()
return {"item": member}
@app.put("/api/members/bulk-sync")
def bulk_sync_members(payload: member_bulk_payload_cls) -> dict[str, list[dict[str, object]]]:
def bulk_sync_members(payload: dict = Body(...)) -> dict[str, list[dict[str, object]]]:
payload = member_bulk_payload_cls.model_validate(payload)
return {"items": replace_members(payload.items)}
@app.put("/api/members/{member_id}")
def update_member(member_id: int, payload: member_payload_cls) -> dict[str, object]:
def update_member(member_id: int, payload: dict = Body(...)) -> dict[str, object]:
payload = member_payload_cls.model_validate(payload)
with get_conn() as conn:
with conn.cursor() as cur:
cur.execute(
@@ -112,8 +117,9 @@ def register_member_routes(
raise HTTPException(status_code=404, detail="Member not found.")
sync_auth_users_from_members(cur)
revision_no = create_history_revision(cur, "member-update", f"Member updated id={member_id}")
sync_member_versions(cur, [member_id], "member-update", revision_no)
sync_seat_assignment_versions(cur, [member_id], "member-update", revision_no)
revision_created_at = fetch_history_revision_created_at(cur, revision_no)
sync_member_versions(cur, [member_id], "member-update", revision_no, revision_created_at)
sync_seat_assignment_versions(cur, [member_id], "member-update", revision_no, revision_created_at)
conn.commit()
return {"item": member}
@@ -126,8 +132,9 @@ def register_member_routes(
if deleted:
sync_auth_users_from_members(cur)
revision_no = create_history_revision(cur, "member-delete", f"Member deleted id={member_id}")
sync_member_versions(cur, [member_id], "member-delete", revision_no)
sync_seat_assignment_versions(cur, [member_id], "member-delete", revision_no)
revision_created_at = fetch_history_revision_created_at(cur, revision_no)
sync_member_versions(cur, [member_id], "member-delete", revision_no, revision_created_at)
sync_seat_assignment_versions(cur, [member_id], "member-delete", revision_no, revision_created_at)
conn.commit()
if not deleted:
raise HTTPException(status_code=404, detail="Member not found.")

View File

@@ -16,7 +16,7 @@
<link rel="stylesheet" href="/design-patterns.css?v=20260401-01">
<link rel="stylesheet" href="/legacy/static/common.css">
<!-- Keep login and common hub defaults aligned with 8080. -->
<link rel="stylesheet" href="/styles.css?v=20260330-01">
<link rel="stylesheet" href="/styles.css?v=20260402-01">
<!-- 8081-only hub overrides must not restyle the login screen. -->
<link rel="stylesheet" href="/styles-8081-design.css?v=20260401-01">
</head>
@@ -105,7 +105,7 @@
<section id="organization-stage" class="main-stage">
<div class="stage-frame">
<!-- Legacy organization keeps its own CSS/JS responsibility under /legacy/static. -->
<iframe id="organization-frame" src="/legacy/organization?v=20260330-02" data-src="/legacy/organization?v=20260330-02" title="조직도 메인 화면"></iframe>
<iframe id="organization-frame" src="/legacy/organization?v=20260402-02" data-src="/legacy/organization?v=20260402-02" title="조직도 메인 화면"></iframe>
</div>
</section>
<section id="project-stage" class="main-stage" hidden>

View File

@@ -344,6 +344,12 @@ body {
padding-right: 8px;
}
.header-date-field select option {
background: var(--color-surface);
color: var(--color-text);
font-weight: 700;
}
.header-date-sep {
color: var(--color-text-muted);
font-size: 12px;

View File

@@ -338,11 +338,16 @@ body {
.modal-content.wide #modal-fields {
min-height: 0;
overflow-y: auto;
padding-right: 4px;
}
.modal-content.wide #modal-footer-area {
margin-top: 0 !important;
flex-shrink: 0;
position: relative;
z-index: 2;
background: var(--color-surface);
}
.member-photo-field {
@@ -761,6 +766,7 @@ body {
height: 300px;
border: 0;
background: var(--color-surface);
pointer-events: none;
}
.seat-preview-card.is-assigned .seat-preview-frame {
@@ -1033,7 +1039,8 @@ body {
.modal-footer-actions {
display: flex;
gap: 10px;
width: 100%;
width: auto;
flex: 1 1 auto;
justify-content: flex-end;
align-items: center;
}
@@ -1045,10 +1052,12 @@ body {
padding: 14px 18px;
transition: all 0.2s ease;
border: 1px solid transparent;
white-space: nowrap;
writing-mode: horizontal-tb;
}
.modal-btn-cancel {
flex: 1 1 0;
flex: 0 1 140px;
background: var(--color-surface-strong);
color: var(--color-text-soft);
border-color: var(--color-border);
@@ -1059,7 +1068,7 @@ body {
}
.modal-btn-save {
flex: 1 1 0;
flex: 0 1 140px;
background: var(--color-header);
color: #fff;
box-shadow: 0 10px 22px rgba(47, 153, 115, 0.2);
@@ -1070,6 +1079,8 @@ body {
}
.modal-btn-delete {
flex: 0 0 132px;
min-width: 132px;
background: rgba(198, 71, 56, 0.12);
color: #a33427;
border-color: rgba(198, 71, 56, 0.2);

View File

@@ -4,6 +4,7 @@ let selectedDept = '전체';
let editingMembers = [];
let collapsedUnits = new Set();
let isListMode = false;
let isListDetailModal = false;
let emptyStateMessage = '서버에 조직 데이터가 없습니다. 상단의 업로드 버튼으로 초기 데이터를 넣어주세요.';
let photoPreviewObjectUrl = null;
let seatMapLayoutCache = null;
@@ -485,6 +486,24 @@ function createNodeDOM(node, parentId) {
return nodeItem;
}
function applyMemberPlacementFromTarget(member, targetLevel, targetName, targetMember = null) {
const targetLevelIndex = levelOrder.indexOf(targetLevel);
if (targetLevelIndex === -1) {
return member;
}
for (let index = 0; index <= targetLevelIndex; index += 1) {
member[levelOrder[index]] = targetMember ? (targetMember[levelOrder[index]] || '') : (index === targetLevelIndex ? targetName : member[levelOrder[index]]);
}
if (!targetMember) {
member[targetLevel] = targetName;
}
for (let index = targetLevelIndex + 1; index < levelOrder.length; index += 1) {
member[levelOrder[index]] = '';
}
rebuildMemberPath(member);
return member;
}
function drawLines() {
const container = document.getElementById('tree-root');
const svg = document.getElementById('svg-canvas');
@@ -628,6 +647,11 @@ function render() {
deptBox.id = deptId;
deptBox.className = 'dept-box';
deptBox.setAttribute('data-level', '부서');
if (isAdmin) {
deptBox.ondragover = (event) => handleDragOver(event);
deptBox.ondragleave = (event) => handleDragLeave(event);
deptBox.ondrop = (event) => handleDrop(event, '부서', deptName);
}
deptBox.innerHTML = `<div class="dept-header ${isAdmin ? 'clickable-title' : ''} ${hasMembers ? 'has-members' : ''}" ${isAdmin ? `onclick="openOrgEditModal('부서', '${jsString(deptName)}')"` : ''}>${deptName} (${totalCount})</div>`;
if (hasMembers) {
@@ -827,6 +851,53 @@ function openAddModal(event) {
openModal(null);
}
function renderListViewShell(defaultDate) {
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';
fieldsArea.style.overflowY = 'hidden';
fieldsArea.innerHTML = `
<div class="list-toolbar">
<div class="list-toolbar-row">
<div class="list-toolbar-group">
<button type="button" onclick="showCurrentListView()" class="list-mode-btn">현재 명단</button>
</div>
<div class="list-toolbar-divider" aria-hidden="true"></div>
<div class="list-toolbar-group list-date-group">
<input type="date" id="list-snapshot-date" value="${escapeHtml(defaultDate)}" class="list-date-input">
<button type="button" onclick="loadSnapshotListView()" class="list-mode-btn">기준일 조회</button>
</div>
<div class="list-toolbar-divider" aria-hidden="true"></div>
<div class="list-toolbar-group list-date-group">
<input type="date" id="list-compare-from" value="${escapeHtml(defaultDate)}" class="list-date-input">
<span class="list-date-separator">~</span>
<input type="date" id="list-compare-to" value="${escapeHtml(defaultDate)}" class="list-date-input">
<button type="button" onclick="loadCompareListView()" class="list-mode-btn">변경 비교</button>
</div>
</div>
<div class="list-toolbar-row">
<input type="text" id="list-search-input" placeholder="이름 또는 부서/팀 검색 (Enter 시 이동)" class="flex-1 bg-[#f6eddd] border-2 border-[#e0d0b4] p-3 rounded-xl text-sm outline-none font-bold text-[#1f2f25] focus:border-[#d68a3a] transition-all" onkeydown="if(event.key==='Enter') handleListSearch(this.value)">
<button type="button" onclick="handleListSearch(document.getElementById('list-search-input').value)" class="bg-[#214634] text-white px-5 rounded-xl font-bold text-sm">검색</button>
</div>
<div id="list-view-status" class="list-view-status"></div>
</div>
<div id="list-table-container" class="overflow-y-auto flex-1 border rounded-xl"></div>
`;
modal.style.display = 'flex';
}
function returnToListViewModal() {
if (!isListMode) {
return;
}
isListDetailModal = false;
renderListViewShell(listViewState.snapshotDate || getDefaultHistoryDate());
renderListViewModalContent();
}
function updateParentList() {
const type = document.getElementById('new-unit-type').value;
const parentSelect = document.getElementById('new-unit-parent');
@@ -1159,7 +1230,8 @@ function openModal(id) {
modal.querySelector('.modal-content').classList.add('wide');
fieldsArea.className = 'flex flex-col w-full';
fieldsArea.style.maxHeight = 'none';
fieldsArea.style.overflowY = 'visible';
fieldsArea.style.overflowY = 'auto';
isListDetailModal = isListMode;
const sourceValues = isListMode ? editingMembers : members;
let orgFields = '<div id="modal-sec-org" class="hidden grid grid-cols-2 gap-3 modal-form-grid">';
@@ -1279,11 +1351,17 @@ function openModal(id) {
}
function closeModal() {
if (isListMode && isListDetailModal) {
returnToListViewModal();
return;
}
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.getElementById('modal-fields').style.overflowY = 'visible';
document.querySelector('.modal-content').classList.remove('wide');
isListDetailModal = false;
isListMode = false;
}
@@ -1337,8 +1415,7 @@ async function saveMember() {
if (!id) {
targetList.push(member);
}
renderListViewTable();
closeModal();
returnToListViewModal();
return;
}
@@ -1367,11 +1444,16 @@ async function deleteMember(id) {
}
if (isListMode) {
const shouldReturnToList = isListDetailModal;
const idx = editingMembers.findIndex((member) => member._id === id);
if (idx !== -1) {
editingMembers.splice(idx, 1);
}
renderListViewTable();
if (shouldReturnToList) {
returnToListViewModal();
} else {
renderListViewTable();
}
return;
}
@@ -1396,44 +1478,11 @@ function openListViewModal(event) {
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;
isListDetailModal = false;
editingMembers = cloneMembers(members);
fieldsArea.innerHTML = `
<div class="list-toolbar">
<div class="list-toolbar-row">
<div class="list-toolbar-group">
<button type="button" onclick="showCurrentListView()" class="list-mode-btn">현재 명단</button>
</div>
<div class="list-toolbar-divider" aria-hidden="true"></div>
<div class="list-toolbar-group list-date-group">
<input type="date" id="list-snapshot-date" value="${escapeHtml(defaultDate)}" class="list-date-input">
<button type="button" onclick="loadSnapshotListView()" class="list-mode-btn">기준일 조회</button>
</div>
<div class="list-toolbar-divider" aria-hidden="true"></div>
<div class="list-toolbar-group list-date-group">
<input type="date" id="list-compare-from" value="${escapeHtml(defaultDate)}" class="list-date-input">
<span class="list-date-separator">~</span>
<input type="date" id="list-compare-to" value="${escapeHtml(defaultDate)}" class="list-date-input">
<button type="button" onclick="loadCompareListView()" class="list-mode-btn">변경 비교</button>
</div>
</div>
<div class="list-toolbar-row">
<input type="text" id="list-search-input" placeholder="이름 또는 부서/팀 검색 (Enter 시 이동)" class="flex-1 bg-[#f6eddd] border-2 border-[#e0d0b4] p-3 rounded-xl text-sm outline-none font-bold text-[#1f2f25] focus:border-[#d68a3a] transition-all" onkeydown="if(event.key==='Enter') handleListSearch(this.value)">
<button type="button" onclick="handleListSearch(document.getElementById('list-search-input').value)" class="bg-[#214634] text-white px-5 rounded-xl font-bold text-sm">검색</button>
</div>
<div id="list-view-status" class="list-view-status"></div>
</div>
<div id="list-table-container" class="overflow-y-auto flex-1 border rounded-xl"></div>
`;
renderListViewShell(defaultDate);
renderListViewModalContent();
modal.style.display = 'flex';
}
async function applyListViewChanges() {
@@ -1697,12 +1746,29 @@ function toggleUnitCollapse(level, name) {
let draggedGroup = null;
function handleListGroupDragStart(event, level, name) {
draggedIdx = null;
draggedGroup = { level, name };
event.dataTransfer.effectAllowed = 'move';
}
function handleListGroupDrop(event, targetLevel, targetName) {
event.preventDefault();
if (draggedIdx !== null) {
const movingMember = editingMembers[draggedIdx];
if (!movingMember) {
return;
}
const moved = editingMembers.splice(draggedIdx, 1)[0];
applyMemberPlacementFromTarget(moved, targetLevel, targetName, null);
let targetIdx = editingMembers.findIndex((member) => member[targetLevel] === targetName);
if (targetIdx === -1) {
targetIdx = editingMembers.length;
}
editingMembers.splice(targetIdx, 0, moved);
draggedIdx = null;
renderListViewTable();
return;
}
if (!draggedGroup || (draggedGroup.level === targetLevel && draggedGroup.name === targetName)) {
return;
}
@@ -1743,6 +1809,7 @@ function handleListSearch(value) {
let draggedIdx = null;
function handleListDragStart(event, index) {
draggedGroup = null;
draggedIdx = index;
event.dataTransfer.effectAllowed = 'move';
event.target.classList.add('dragging');
@@ -1753,7 +1820,11 @@ function handleListDrop(event, targetIdx) {
if (draggedIdx === null || draggedIdx === targetIdx) {
return;
}
const targetMember = editingMembers[targetIdx] || null;
const moved = editingMembers.splice(draggedIdx, 1)[0];
if (targetMember) {
applyMemberPlacementFromTarget(moved, levelOrder[levelOrder.length - 1], targetMember[levelOrder[levelOrder.length - 1]] || '', targetMember);
}
editingMembers.splice(targetIdx, 0, moved);
draggedIdx = null;
renderListViewTable();
@@ -1804,13 +1875,10 @@ async function handleDrop(event, targetLevel, targetName) {
}
const nextMembers = cloneMembers(members);
const moved = nextMembers[memberIndex];
for (let index = 0; index <= targetLevelIndex; index += 1) {
moved[levelOrder[index]] = targetMember ? targetMember[levelOrder[index]] : targetName;
if (targetLevelIndex === -1) {
return;
}
for (let index = targetLevelIndex + 1; index < levelOrder.length; index += 1) {
moved[levelOrder[index]] = '';
}
rebuildMemberPath(moved);
applyMemberPlacementFromTarget(moved, targetLevel, targetName, targetMember);
nextMembers.splice(memberIndex, 1);
nextMembers.push(moved);
await syncMembers(nextMembers);
@@ -1859,10 +1927,7 @@ async function handleDropMember(event, targetId) {
}
const moved = nextMembers[movingIdx];
const target = nextMembers[targetIdx];
levelOrder.forEach((level) => {
moved[level] = target[level];
});
rebuildMemberPath(moved);
applyMemberPlacementFromTarget(moved, levelOrder[levelOrder.length - 1], target[levelOrder[levelOrder.length - 1]] || '', target);
nextMembers.splice(movingIdx, 1);
targetIdx = nextMembers.findIndex((member) => member._id === targetId);
nextMembers.splice(insertAfter ? targetIdx + 1 : targetIdx, 0, moved);