From 3a611972c10502113c195015e6e026703ca62e7c Mon Sep 17 00:00:00 2001 From: taekyoungc Date: Mon, 13 Jul 2026 12:26:58 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EB=A9=94=EB=89=B4=20=EB=93=B1=EB=A1=9D?= =?UTF-8?q?/=EC=88=98=EC=A0=95=20=EC=8B=9C=20=EC=83=81=EC=9C=84=C2=B7?= =?UTF-8?q?=ED=95=98=EC=9C=84=20=EC=9C=84=EC=B9=98=20=EC=84=A0=ED=83=9D=20?= =?UTF-8?q?+=20=EC=A7=80=EC=9E=90=EC=B2=B4=20=EB=8F=99=EA=B8=B0=ED=99=94?= =?UTF-8?q?=20=EC=84=B1=EB=8A=A5=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 등록/수정 폼에 상위/하위 선택(상위 메뉴 지정), 기존 메뉴도 상위↔하위 이동 가능 - 하위 보유 메뉴의 하위 변경 차단 등 검증 - syncTypeToAllLgs를 dep별 batch insert로 재작성 → 메뉴 저장 시 원격 DB 왕복 급감(30초 타임아웃 해소) Co-Authored-By: Claude Opus 4.8 --- app/Controllers/Admin/Menu.php | 34 +++++++++++++ app/Models/MenuModel.php | 60 ++++++++++++++-------- app/Views/admin/menu/index.php | 91 ++++++++++++++++++++++++++++++++-- 3 files changed, 162 insertions(+), 23 deletions(-) diff --git a/app/Controllers/Admin/Menu.php b/app/Controllers/Admin/Menu.php index fc5f125..bf7280e 100644 --- a/app/Controllers/Admin/Menu.php +++ b/app/Controllers/Admin/Menu.php @@ -210,7 +210,41 @@ class Menu extends BaseController 'mm_level' => $this->normalizeMmLevel((int) $row->mt_idx), 'mm_is_view' => $this->request->getPost('mm_is_view') ? 'Y' : 'N', ]; + + // 위치(상위↔하위) 변경 지원: mm_pidx 가 바뀌면 상위/하위로 이동 + $oldPidx = (int) $row->mm_pidx; + $newPidx = (int) $this->request->getPost('mm_pidx'); + $newDep = $newPidx > 0 ? 1 : 0; + $positionChanged = $newPidx !== $oldPidx; + if ($positionChanged) { + if ($newPidx === $id) { + return $this->menusRedirect((int) $row->mt_idx)->with('error', '자기 자신을 상위 메뉴로 지정할 수 없습니다.'); + } + // 하위 메뉴를 보유한 상위 메뉴는 하위로 변경 불가(2단 구조 유지) + $childCnt = $this->menuModel->where('mt_idx', (int) $row->mt_idx)->where('lg_idx', $lgIdx)->where('mm_pidx', $id)->countAllResults(); + if ($newPidx > 0 && $childCnt > 0) { + return $this->menusRedirect((int) $row->mt_idx)->with('error', '하위 메뉴가 있는 상위 메뉴는 하위로 변경할 수 없습니다. 먼저 하위 메뉴를 옮겨 주세요.'); + } + if ($newPidx > 0) { + $parent = $this->menuModel->find($newPidx); + if (! $parent || (int) $parent->lg_idx !== $lgIdx || (int) $parent->mt_idx !== (int) $row->mt_idx || (int) $parent->mm_dep !== 0) { + return $this->menusRedirect((int) $row->mt_idx)->with('error', '올바른 상위 메뉴를 선택해 주세요.'); + } + } + $data['mm_pidx'] = $newPidx; + $data['mm_dep'] = $newDep; + $data['mm_num'] = $this->menuModel->getNextNum((int) $row->mt_idx, $lgIdx, $newPidx, $newDep); + } + $this->menuModel->update($id, $data); + if ($positionChanged) { + if ($oldPidx > 0) { + $this->menuModel->updateCnode($oldPidx, -1); + } + if ($newPidx > 0) { + $this->menuModel->updateCnode($newPidx, 1); + } + } $this->menuModel->pruneInventoryManagementMenus((int) $row->mt_idx, $lgIdx); $this->menuModel->syncTypeToAllLgs((int) $row->mt_idx, $lgIdx); return $this->menusRedirect((int) $row->mt_idx)->with('success', '메뉴가 수정되었습니다.'); diff --git a/app/Models/MenuModel.php b/app/Models/MenuModel.php index 00213ca..be5d073 100644 --- a/app/Models/MenuModel.php +++ b/app/Models/MenuModel.php @@ -270,6 +270,13 @@ class MenuModel extends Model ->get() ->getResultArray(); + // 깊이(dep) 오름차순으로 그룹핑 — 상위부터 삽입해 자식의 mm_pidx를 새 ID로 재매핑 + $byDep = []; + foreach ($source as $row) { + $byDep[(int) ($row->mm_dep ?? 0)][] = $row; + } + ksort($byDep); + foreach ($lgRows as $lgRow) { $destLg = (int) ($lgRow['lg_idx'] ?? 0); if ($destLg <= 0 || $destLg === $sourceLg) { @@ -281,28 +288,41 @@ class MenuModel extends Model ->where('lg_idx', $destLg) ->delete(); + // 원격 DB 왕복 최소화: 레벨(dep)별로 batch insert 후, 삽입 순서(mm_idx ASC)로 old→new ID 매핑 $idMap = []; - foreach ($source as $row) { - $oldId = (int) ($row->mm_idx ?? 0); - $oldP = (int) ($row->mm_pidx ?? 0); - $newPidx = 0; - if ($oldP > 0 && isset($idMap[$oldP])) { - $newPidx = (int) $idMap[$oldP]; + foreach ($byDep as $dep => $levelRows) { + $batch = []; + foreach ($levelRows as $row) { + $oldP = (int) ($row->mm_pidx ?? 0); + $newPidx = ($oldP > 0 && isset($idMap[$oldP])) ? (int) $idMap[$oldP] : 0; + $batch[] = [ + 'mt_idx' => $mtIdx, + 'lg_idx' => $destLg, + 'mm_name' => (string) ($row->mm_name ?? ''), + 'mm_link' => (string) ($row->mm_link ?? ''), + 'mm_pidx' => $newPidx, + 'mm_dep' => (int) $dep, + 'mm_num' => (int) ($row->mm_num ?? 0), + 'mm_cnode' => (int) ($row->mm_cnode ?? 0), + 'mm_level' => (string) ($row->mm_level ?? ''), + 'mm_is_view' => (string) ($row->mm_is_view ?? 'Y'), + ]; + } + if ($batch === []) { + continue; + } + $this->insertBatch($batch); + // 방금 삽입한 이 레벨의 행을 삽입 순서(mm_idx ASC)대로 조회해 원본과 1:1 매핑 + $inserted = $this->where('mt_idx', $mtIdx) + ->where('lg_idx', $destLg) + ->where('mm_dep', (int) $dep) + ->orderBy('mm_idx', 'ASC') + ->findAll(); + foreach ($levelRows as $i => $row) { + if (isset($inserted[$i])) { + $idMap[(int) ($row->mm_idx ?? 0)] = (int) $inserted[$i]->mm_idx; + } } - - $this->insert([ - 'mt_idx' => $mtIdx, - 'lg_idx' => $destLg, - 'mm_name' => (string) ($row->mm_name ?? ''), - 'mm_link' => (string) ($row->mm_link ?? ''), - 'mm_pidx' => $newPidx, - 'mm_dep' => (int) ($row->mm_dep ?? 0), - 'mm_num' => (int) ($row->mm_num ?? 0), - 'mm_cnode' => (int) ($row->mm_cnode ?? 0), - 'mm_level' => (string) ($row->mm_level ?? ''), - 'mm_is_view' => (string) ($row->mm_is_view ?? 'Y'), - ]); - $idMap[$oldId] = (int) $this->getInsertID(); } $this->db->transComplete(); } diff --git a/app/Views/admin/menu/index.php b/app/Views/admin/menu/index.php index 010e8e1..99ab50d 100644 --- a/app/Views/admin/menu/index.php +++ b/app/Views/admin/menu/index.php @@ -145,7 +145,8 @@ $adminMenuListResolveHref = static function (string $rawLink) use ($menuListReso data-link="mm_link) ?>" data-level="mm_level) ?>" data-view="mm_is_view ?>" - data-dep="mm_dep ?>"> + data-dep="mm_dep ?>" + data-pidx="mm_pidx ?>"> 수정 @@ -190,6 +191,26 @@ $adminMenuListResolveHref = static function (string $rawLink) use ($menuListReso +
@@ -259,6 +280,50 @@ $adminMenuListResolveHref = static function (string $rawLink) use ($menuListReso const editIdInput = document.getElementById('mm_idx_edit'); const mmPidxInput = form.querySelector('[name="mm_pidx"]'); const mmDepInput = form.querySelector('[name="mm_dep"]'); + const posWrap = document.getElementById('menu-position-wrap'); + const posTop = document.getElementById('pos-top'); + const posChild = document.getElementById('pos-child'); + const parentSelect = document.getElementById('mm-parent-select'); + + // 상위/하위 위치 선택 → 숨은 mm_pidx / mm_dep 동기화 + function applyParentFromSelect() { + const opt = parentSelect.options[parentSelect.selectedIndex]; + if (opt && opt.value) { + mmPidxInput.value = opt.value; + mmDepInput.value = String((parseInt(opt.dataset.dep || '0', 10)) + 1); + } else { + mmPidxInput.value = '0'; + mmDepInput.value = '1'; + } + } + function setPosition(type, parentId) { + if (type === 'child') { + if (posChild) posChild.checked = true; + parentSelect.classList.remove('hidden'); + if (parentId != null) parentSelect.value = String(parentId); + applyParentFromSelect(); + } else { + if (posTop) posTop.checked = true; + parentSelect.classList.add('hidden'); + mmPidxInput.value = '0'; + mmDepInput.value = '0'; + } + } + if (posTop) posTop.addEventListener('change', function () { if (posTop.checked) setPosition('top'); }); + if (posChild) posChild.addEventListener('change', function () { if (posChild.checked) setPosition('child', parentSelect.value); }); + if (parentSelect) parentSelect.addEventListener('change', applyParentFromSelect); + // 하위 선택인데 상위 미지정이면 등록 차단 + function enableAllParentOptions() { + if (!parentSelect) return; + Array.prototype.forEach.call(parentSelect.options, function (o) { o.disabled = false; }); + } + form.addEventListener('submit', function (e) { + if (posChild && posChild.checked && !parentSelect.value) { + e.preventDefault(); + alert('하위 메뉴로 지정하려면 상위 메뉴를 선택해 주세요.'); + } + }); + const levelAll = document.getElementById('mm_level_all'); const levelCbs = document.querySelectorAll('.mm-level-cb'); const isAdminType = '' === 'admin'; @@ -298,11 +363,23 @@ $adminMenuListResolveHref = static function (string $rawLink) use ($menuListReso const level = (this.dataset.level || '').toString().trim(); const view = this.dataset.view || 'Y'; const dep = parseInt(this.dataset.dep || '0', 10); + const pidx = parseInt(this.dataset.pidx || '0', 10); form.action = '' + id; form.querySelector('[name="mm_name"]').value = name; form.querySelector('[name="mm_link"]').value = link; - mmPidxInput.value = '0'; - mmDepInput.value = String(dep); + // 위치(상위/하위) 선택 UI를 현재 위치로 표시 — 수정 시에도 상위↔하위 이동 가능 + if (posWrap) posWrap.style.display = ''; + // 자기 자신은 상위 후보에서 제외 + if (parentSelect) { + Array.prototype.forEach.call(parentSelect.options, function (o) { + o.disabled = (o.value === String(id)); + }); + } + if (pidx > 0) { + setPosition('child', pidx); + } else { + setPosition('top'); + } if (!isAdminType && levelAll) { levelAll.checked = (level === ''); levelCbs.forEach(function(cb){ @@ -340,6 +417,10 @@ $adminMenuListResolveHref = static function (string $rawLink) use ($menuListReso formTitle.textContent = '하위 메뉴 등록'; btnSubmit.textContent = '등록'; btnCancel.style.display = 'inline-block'; + // 위치 선택 UI를 '하위'로 반영(선택한 상위 메뉴 표시) + if (posWrap) posWrap.style.display = ''; + enableAllParentOptions(); + setPosition('child', parentId); // 노출 기본값 재설정 form.querySelector('[name="mm_is_view"]').checked = true; if (!isAdminType && levelAll) { @@ -368,6 +449,10 @@ $adminMenuListResolveHref = static function (string $rawLink) use ($menuListReso formTitle.textContent = '메뉴 등록'; btnSubmit.textContent = '등록'; btnCancel.style.display = 'none'; + // 위치 선택 UI 복원 → 기본 '상위' + if (posWrap) posWrap.style.display = ''; + enableAllParentOptions(); + setPosition('top'); }); // 메뉴 목록 행 드래그 정렬 (마우스 이벤트 기반)