Fix organization member editing and drag sync
This commit is contained in:
@@ -8,8 +8,8 @@
|
|||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
<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" />
|
<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>
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
<link rel="stylesheet" href="/legacy/static/common.css?v=20260331-01" />
|
<link rel="stylesheet" href="/legacy/static/common.css?v=20260402-02" />
|
||||||
<link rel="stylesheet" href="/legacy/static/organization.css?v=20260331-01" />
|
<link rel="stylesheet" href="/legacy/static/organization.css?v=20260402-02" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<input type="file" id="upload-excel" class="hidden" accept=".xlsx, .csv" />
|
<input type="file" id="upload-excel" class="hidden" accept=".xlsx, .csv" />
|
||||||
@@ -60,6 +60,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/legacy/static/organization.js?v=20260331-01"></script>
|
<script src="/legacy/static/organization.js?v=20260402-02"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ from __future__ import annotations
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Callable
|
from typing import Callable
|
||||||
|
|
||||||
from fastapi import FastAPI, File, HTTPException, UploadFile
|
from fastapi import Body, FastAPI, File, HTTPException, UploadFile
|
||||||
|
|
||||||
|
|
||||||
def register_member_routes(
|
def register_member_routes(
|
||||||
@@ -19,6 +19,7 @@ def register_member_routes(
|
|||||||
serialize_member_payload: Callable[[object, int], tuple[object, ...]],
|
serialize_member_payload: Callable[[object, int], tuple[object, ...]],
|
||||||
sync_auth_users_from_members: Callable[[object], None],
|
sync_auth_users_from_members: Callable[[object], None],
|
||||||
create_history_revision: Callable[[object, str, str], int],
|
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_member_versions: Callable[[object, list[int], str, int], None],
|
||||||
sync_seat_assignment_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]]],
|
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)}
|
return {"items": build_member_compare_items(from_items, to_items)}
|
||||||
|
|
||||||
@app.post("/api/members")
|
@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 get_conn() as conn:
|
||||||
with conn.cursor() as cur:
|
with conn.cursor() as cur:
|
||||||
cur.execute("SELECT COALESCE(MAX(sort_order), -1) + 1 AS next_order FROM members")
|
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()
|
member = cur.fetchone()
|
||||||
sync_auth_users_from_members(cur)
|
sync_auth_users_from_members(cur)
|
||||||
revision_no = create_history_revision(cur, "member-create", f"Member created id={int(member['id'])}")
|
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()
|
conn.commit()
|
||||||
return {"item": member}
|
return {"item": member}
|
||||||
|
|
||||||
@app.put("/api/members/bulk-sync")
|
@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)}
|
return {"items": replace_members(payload.items)}
|
||||||
|
|
||||||
@app.put("/api/members/{member_id}")
|
@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 get_conn() as conn:
|
||||||
with conn.cursor() as cur:
|
with conn.cursor() as cur:
|
||||||
cur.execute(
|
cur.execute(
|
||||||
@@ -112,8 +117,9 @@ def register_member_routes(
|
|||||||
raise HTTPException(status_code=404, detail="Member not found.")
|
raise HTTPException(status_code=404, detail="Member not found.")
|
||||||
sync_auth_users_from_members(cur)
|
sync_auth_users_from_members(cur)
|
||||||
revision_no = create_history_revision(cur, "member-update", f"Member updated id={member_id}")
|
revision_no = create_history_revision(cur, "member-update", f"Member updated id={member_id}")
|
||||||
sync_member_versions(cur, [member_id], "member-update", revision_no)
|
revision_created_at = fetch_history_revision_created_at(cur, revision_no)
|
||||||
sync_seat_assignment_versions(cur, [member_id], "member-update", 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()
|
conn.commit()
|
||||||
return {"item": member}
|
return {"item": member}
|
||||||
|
|
||||||
@@ -126,8 +132,9 @@ def register_member_routes(
|
|||||||
if deleted:
|
if deleted:
|
||||||
sync_auth_users_from_members(cur)
|
sync_auth_users_from_members(cur)
|
||||||
revision_no = create_history_revision(cur, "member-delete", f"Member deleted id={member_id}")
|
revision_no = create_history_revision(cur, "member-delete", f"Member deleted id={member_id}")
|
||||||
sync_member_versions(cur, [member_id], "member-delete", revision_no)
|
revision_created_at = fetch_history_revision_created_at(cur, revision_no)
|
||||||
sync_seat_assignment_versions(cur, [member_id], "member-delete", 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()
|
conn.commit()
|
||||||
if not deleted:
|
if not deleted:
|
||||||
raise HTTPException(status_code=404, detail="Member not found.")
|
raise HTTPException(status_code=404, detail="Member not found.")
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
<link rel="stylesheet" href="/design-patterns.css?v=20260401-01">
|
<link rel="stylesheet" href="/design-patterns.css?v=20260401-01">
|
||||||
<link rel="stylesheet" href="/legacy/static/common.css">
|
<link rel="stylesheet" href="/legacy/static/common.css">
|
||||||
<!-- Keep login and common hub defaults aligned with 8080. -->
|
<!-- 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. -->
|
<!-- 8081-only hub overrides must not restyle the login screen. -->
|
||||||
<link rel="stylesheet" href="/styles-8081-design.css?v=20260401-01">
|
<link rel="stylesheet" href="/styles-8081-design.css?v=20260401-01">
|
||||||
</head>
|
</head>
|
||||||
@@ -105,7 +105,7 @@
|
|||||||
<section id="organization-stage" class="main-stage">
|
<section id="organization-stage" class="main-stage">
|
||||||
<div class="stage-frame">
|
<div class="stage-frame">
|
||||||
<!-- Legacy organization keeps its own CSS/JS responsibility under /legacy/static. -->
|
<!-- 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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<section id="project-stage" class="main-stage" hidden>
|
<section id="project-stage" class="main-stage" hidden>
|
||||||
|
|||||||
@@ -344,6 +344,12 @@ body {
|
|||||||
padding-right: 8px;
|
padding-right: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.header-date-field select option {
|
||||||
|
background: var(--color-surface);
|
||||||
|
color: var(--color-text);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
.header-date-sep {
|
.header-date-sep {
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
|
|||||||
@@ -338,11 +338,16 @@ body {
|
|||||||
|
|
||||||
.modal-content.wide #modal-fields {
|
.modal-content.wide #modal-fields {
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding-right: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-content.wide #modal-footer-area {
|
.modal-content.wide #modal-footer-area {
|
||||||
margin-top: 0 !important;
|
margin-top: 0 !important;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
|
background: var(--color-surface);
|
||||||
}
|
}
|
||||||
|
|
||||||
.member-photo-field {
|
.member-photo-field {
|
||||||
@@ -761,6 +766,7 @@ body {
|
|||||||
height: 300px;
|
height: 300px;
|
||||||
border: 0;
|
border: 0;
|
||||||
background: var(--color-surface);
|
background: var(--color-surface);
|
||||||
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.seat-preview-card.is-assigned .seat-preview-frame {
|
.seat-preview-card.is-assigned .seat-preview-frame {
|
||||||
@@ -1033,7 +1039,8 @@ body {
|
|||||||
.modal-footer-actions {
|
.modal-footer-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
width: 100%;
|
width: auto;
|
||||||
|
flex: 1 1 auto;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
@@ -1045,10 +1052,12 @@ body {
|
|||||||
padding: 14px 18px;
|
padding: 14px 18px;
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
border: 1px solid transparent;
|
border: 1px solid transparent;
|
||||||
|
white-space: nowrap;
|
||||||
|
writing-mode: horizontal-tb;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-btn-cancel {
|
.modal-btn-cancel {
|
||||||
flex: 1 1 0;
|
flex: 0 1 140px;
|
||||||
background: var(--color-surface-strong);
|
background: var(--color-surface-strong);
|
||||||
color: var(--color-text-soft);
|
color: var(--color-text-soft);
|
||||||
border-color: var(--color-border);
|
border-color: var(--color-border);
|
||||||
@@ -1059,7 +1068,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.modal-btn-save {
|
.modal-btn-save {
|
||||||
flex: 1 1 0;
|
flex: 0 1 140px;
|
||||||
background: var(--color-header);
|
background: var(--color-header);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
box-shadow: 0 10px 22px rgba(47, 153, 115, 0.2);
|
box-shadow: 0 10px 22px rgba(47, 153, 115, 0.2);
|
||||||
@@ -1070,6 +1079,8 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.modal-btn-delete {
|
.modal-btn-delete {
|
||||||
|
flex: 0 0 132px;
|
||||||
|
min-width: 132px;
|
||||||
background: rgba(198, 71, 56, 0.12);
|
background: rgba(198, 71, 56, 0.12);
|
||||||
color: #a33427;
|
color: #a33427;
|
||||||
border-color: rgba(198, 71, 56, 0.2);
|
border-color: rgba(198, 71, 56, 0.2);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ let selectedDept = '전체';
|
|||||||
let editingMembers = [];
|
let editingMembers = [];
|
||||||
let collapsedUnits = new Set();
|
let collapsedUnits = new Set();
|
||||||
let isListMode = false;
|
let isListMode = false;
|
||||||
|
let isListDetailModal = false;
|
||||||
let emptyStateMessage = '서버에 조직 데이터가 없습니다. 상단의 업로드 버튼으로 초기 데이터를 넣어주세요.';
|
let emptyStateMessage = '서버에 조직 데이터가 없습니다. 상단의 업로드 버튼으로 초기 데이터를 넣어주세요.';
|
||||||
let photoPreviewObjectUrl = null;
|
let photoPreviewObjectUrl = null;
|
||||||
let seatMapLayoutCache = null;
|
let seatMapLayoutCache = null;
|
||||||
@@ -485,6 +486,24 @@ function createNodeDOM(node, parentId) {
|
|||||||
return nodeItem;
|
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() {
|
function drawLines() {
|
||||||
const container = document.getElementById('tree-root');
|
const container = document.getElementById('tree-root');
|
||||||
const svg = document.getElementById('svg-canvas');
|
const svg = document.getElementById('svg-canvas');
|
||||||
@@ -628,6 +647,11 @@ function render() {
|
|||||||
deptBox.id = deptId;
|
deptBox.id = deptId;
|
||||||
deptBox.className = 'dept-box';
|
deptBox.className = 'dept-box';
|
||||||
deptBox.setAttribute('data-level', '부서');
|
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>`;
|
deptBox.innerHTML = `<div class="dept-header ${isAdmin ? 'clickable-title' : ''} ${hasMembers ? 'has-members' : ''}" ${isAdmin ? `onclick="openOrgEditModal('부서', '${jsString(deptName)}')"` : ''}>${deptName} (${totalCount})</div>`;
|
||||||
|
|
||||||
if (hasMembers) {
|
if (hasMembers) {
|
||||||
@@ -827,6 +851,53 @@ function openAddModal(event) {
|
|||||||
openModal(null);
|
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() {
|
function updateParentList() {
|
||||||
const type = document.getElementById('new-unit-type').value;
|
const type = document.getElementById('new-unit-type').value;
|
||||||
const parentSelect = document.getElementById('new-unit-parent');
|
const parentSelect = document.getElementById('new-unit-parent');
|
||||||
@@ -1159,7 +1230,8 @@ function openModal(id) {
|
|||||||
modal.querySelector('.modal-content').classList.add('wide');
|
modal.querySelector('.modal-content').classList.add('wide');
|
||||||
fieldsArea.className = 'flex flex-col w-full';
|
fieldsArea.className = 'flex flex-col w-full';
|
||||||
fieldsArea.style.maxHeight = 'none';
|
fieldsArea.style.maxHeight = 'none';
|
||||||
fieldsArea.style.overflowY = 'visible';
|
fieldsArea.style.overflowY = 'auto';
|
||||||
|
isListDetailModal = isListMode;
|
||||||
|
|
||||||
const sourceValues = isListMode ? editingMembers : members;
|
const sourceValues = isListMode ? editingMembers : members;
|
||||||
let orgFields = '<div id="modal-sec-org" class="hidden grid grid-cols-2 gap-3 modal-form-grid">';
|
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() {
|
function closeModal() {
|
||||||
|
if (isListMode && isListDetailModal) {
|
||||||
|
returnToListViewModal();
|
||||||
|
return;
|
||||||
|
}
|
||||||
resetPhotoPreviewObjectUrl();
|
resetPhotoPreviewObjectUrl();
|
||||||
document.getElementById('modal').style.display = 'none';
|
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').className = 'grid grid-cols-2 gap-x-8 gap-y-5';
|
||||||
document.getElementById('modal-fields').style.maxHeight = 'none';
|
document.getElementById('modal-fields').style.maxHeight = 'none';
|
||||||
|
document.getElementById('modal-fields').style.overflowY = 'visible';
|
||||||
document.querySelector('.modal-content').classList.remove('wide');
|
document.querySelector('.modal-content').classList.remove('wide');
|
||||||
|
isListDetailModal = false;
|
||||||
isListMode = false;
|
isListMode = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1337,8 +1415,7 @@ async function saveMember() {
|
|||||||
if (!id) {
|
if (!id) {
|
||||||
targetList.push(member);
|
targetList.push(member);
|
||||||
}
|
}
|
||||||
renderListViewTable();
|
returnToListViewModal();
|
||||||
closeModal();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1367,11 +1444,16 @@ async function deleteMember(id) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (isListMode) {
|
if (isListMode) {
|
||||||
|
const shouldReturnToList = isListDetailModal;
|
||||||
const idx = editingMembers.findIndex((member) => member._id === id);
|
const idx = editingMembers.findIndex((member) => member._id === id);
|
||||||
if (idx !== -1) {
|
if (idx !== -1) {
|
||||||
editingMembers.splice(idx, 1);
|
editingMembers.splice(idx, 1);
|
||||||
}
|
}
|
||||||
|
if (shouldReturnToList) {
|
||||||
|
returnToListViewModal();
|
||||||
|
} else {
|
||||||
renderListViewTable();
|
renderListViewTable();
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1396,44 +1478,11 @@ function openListViewModal(event) {
|
|||||||
listViewState.compareToDate = defaultDate;
|
listViewState.compareToDate = defaultDate;
|
||||||
listViewState.snapshotMembers = [];
|
listViewState.snapshotMembers = [];
|
||||||
listViewState.compareItems = [];
|
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;
|
isListMode = true;
|
||||||
|
isListDetailModal = false;
|
||||||
editingMembers = cloneMembers(members);
|
editingMembers = cloneMembers(members);
|
||||||
fieldsArea.innerHTML = `
|
renderListViewShell(defaultDate);
|
||||||
<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>
|
|
||||||
`;
|
|
||||||
renderListViewModalContent();
|
renderListViewModalContent();
|
||||||
modal.style.display = 'flex';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function applyListViewChanges() {
|
async function applyListViewChanges() {
|
||||||
@@ -1697,12 +1746,29 @@ function toggleUnitCollapse(level, name) {
|
|||||||
|
|
||||||
let draggedGroup = null;
|
let draggedGroup = null;
|
||||||
function handleListGroupDragStart(event, level, name) {
|
function handleListGroupDragStart(event, level, name) {
|
||||||
|
draggedIdx = null;
|
||||||
draggedGroup = { level, name };
|
draggedGroup = { level, name };
|
||||||
event.dataTransfer.effectAllowed = 'move';
|
event.dataTransfer.effectAllowed = 'move';
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleListGroupDrop(event, targetLevel, targetName) {
|
function handleListGroupDrop(event, targetLevel, targetName) {
|
||||||
event.preventDefault();
|
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)) {
|
if (!draggedGroup || (draggedGroup.level === targetLevel && draggedGroup.name === targetName)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1743,6 +1809,7 @@ function handleListSearch(value) {
|
|||||||
|
|
||||||
let draggedIdx = null;
|
let draggedIdx = null;
|
||||||
function handleListDragStart(event, index) {
|
function handleListDragStart(event, index) {
|
||||||
|
draggedGroup = null;
|
||||||
draggedIdx = index;
|
draggedIdx = index;
|
||||||
event.dataTransfer.effectAllowed = 'move';
|
event.dataTransfer.effectAllowed = 'move';
|
||||||
event.target.classList.add('dragging');
|
event.target.classList.add('dragging');
|
||||||
@@ -1753,7 +1820,11 @@ function handleListDrop(event, targetIdx) {
|
|||||||
if (draggedIdx === null || draggedIdx === targetIdx) {
|
if (draggedIdx === null || draggedIdx === targetIdx) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const targetMember = editingMembers[targetIdx] || null;
|
||||||
const moved = editingMembers.splice(draggedIdx, 1)[0];
|
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);
|
editingMembers.splice(targetIdx, 0, moved);
|
||||||
draggedIdx = null;
|
draggedIdx = null;
|
||||||
renderListViewTable();
|
renderListViewTable();
|
||||||
@@ -1804,13 +1875,10 @@ async function handleDrop(event, targetLevel, targetName) {
|
|||||||
}
|
}
|
||||||
const nextMembers = cloneMembers(members);
|
const nextMembers = cloneMembers(members);
|
||||||
const moved = nextMembers[memberIndex];
|
const moved = nextMembers[memberIndex];
|
||||||
for (let index = 0; index <= targetLevelIndex; index += 1) {
|
if (targetLevelIndex === -1) {
|
||||||
moved[levelOrder[index]] = targetMember ? targetMember[levelOrder[index]] : targetName;
|
return;
|
||||||
}
|
}
|
||||||
for (let index = targetLevelIndex + 1; index < levelOrder.length; index += 1) {
|
applyMemberPlacementFromTarget(moved, targetLevel, targetName, targetMember);
|
||||||
moved[levelOrder[index]] = '';
|
|
||||||
}
|
|
||||||
rebuildMemberPath(moved);
|
|
||||||
nextMembers.splice(memberIndex, 1);
|
nextMembers.splice(memberIndex, 1);
|
||||||
nextMembers.push(moved);
|
nextMembers.push(moved);
|
||||||
await syncMembers(nextMembers);
|
await syncMembers(nextMembers);
|
||||||
@@ -1859,10 +1927,7 @@ async function handleDropMember(event, targetId) {
|
|||||||
}
|
}
|
||||||
const moved = nextMembers[movingIdx];
|
const moved = nextMembers[movingIdx];
|
||||||
const target = nextMembers[targetIdx];
|
const target = nextMembers[targetIdx];
|
||||||
levelOrder.forEach((level) => {
|
applyMemberPlacementFromTarget(moved, levelOrder[levelOrder.length - 1], target[levelOrder[levelOrder.length - 1]] || '', target);
|
||||||
moved[level] = target[level];
|
|
||||||
});
|
|
||||||
rebuildMemberPath(moved);
|
|
||||||
nextMembers.splice(movingIdx, 1);
|
nextMembers.splice(movingIdx, 1);
|
||||||
targetIdx = nextMembers.findIndex((member) => member._id === targetId);
|
targetIdx = nextMembers.findIndex((member) => member._id === targetId);
|
||||||
nextMembers.splice(insertAfter ? targetIdx + 1 : targetIdx, 0, moved);
|
nextMembers.splice(insertAfter ? targetIdx + 1 : targetIdx, 0, moved);
|
||||||
|
|||||||
Reference in New Issue
Block a user