feat: 메뉴 등록/수정 시 상위·하위 위치 선택 + 지자체 동기화 성능 개선

- 등록/수정 폼에 상위/하위 선택(상위 메뉴 지정), 기존 메뉴도 상위↔하위 이동 가능
- 하위 보유 메뉴의 하위 변경 차단 등 검증
- syncTypeToAllLgs를 dep별 batch insert로 재작성 → 메뉴 저장 시 원격 DB 왕복 급감(30초 타임아웃 해소)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
taekyoungc
2026-07-13 12:26:58 +09:00
parent 9365848f9d
commit 3a611972c1
3 changed files with 162 additions and 23 deletions

View File

@@ -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', '메뉴가 수정되었습니다.');

View File

@@ -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();
}

View File

@@ -145,7 +145,8 @@ $adminMenuListResolveHref = static function (string $rawLink) use ($menuListReso
data-link="<?= esc($row->mm_link) ?>"
data-level="<?= esc($row->mm_level) ?>"
data-view="<?= (string) $row->mm_is_view ?>"
data-dep="<?= (int) $row->mm_dep ?>">
data-dep="<?= (int) $row->mm_dep ?>"
data-pidx="<?= (int) $row->mm_pidx ?>">
수정
</button>
<?php if ($dep === 0): ?>
@@ -190,6 +191,26 @@ $adminMenuListResolveHref = static function (string $rawLink) use ($menuListReso
<label class="block text-sm font-medium text-gray-700">링크</label>
<input type="text" name="mm_link" id="mm_link" class="border border-gray-300 rounded px-2 py-1 w-full text-sm" placeholder="예: admin/users"/>
</div>
<div class="space-y-2 mb-3" id="menu-position-wrap">
<label class="block text-sm font-medium text-gray-700">메뉴 위치</label>
<div class="flex flex-wrap gap-x-4 gap-y-1">
<label class="inline-flex items-center gap-1 cursor-pointer">
<input type="radio" name="pos_type" value="top" id="pos-top" checked/>
<span class="text-sm">상위 메뉴로 등록</span>
</label>
<label class="inline-flex items-center gap-1 cursor-pointer">
<input type="radio" name="pos_type" value="child" id="pos-child"/>
<span class="text-sm">하위 메뉴로 등록</span>
</label>
</div>
<select id="mm-parent-select" class="border border-gray-300 rounded px-2 py-1 w-full text-sm mt-1 hidden">
<option value="">— 상위 메뉴 선택 —</option>
<?php foreach ($list as $prow): ?>
<?php if ((int) $prow->mm_dep !== 0) { continue; } ?>
<option value="<?= (int) $prow->mm_idx ?>" data-dep="<?= (int) $prow->mm_dep ?>"><?= esc($prow->mm_name) ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="space-y-2 mb-3">
<label class="block text-sm font-medium text-gray-700">노출 대상</label>
<?php if ($mtCode === 'admin'): ?>
@@ -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 = '<?= esc($mtCode) ?>' === '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 = '<?= base_url('admin/menus/update/') ?>' + 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');
});
// 메뉴 목록 행 드래그 정렬 (마우스 이벤트 기반)