feat: 메뉴 등록/수정 시 상위·하위 위치 선택 + 지자체 동기화 성능 개선
- 등록/수정 폼에 상위/하위 선택(상위 메뉴 지정), 기존 메뉴도 상위↔하위 이동 가능 - 하위 보유 메뉴의 하위 변경 차단 등 검증 - syncTypeToAllLgs를 dep별 batch insert로 재작성 → 메뉴 저장 시 원격 DB 왕복 급감(30초 타임아웃 해소) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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', '메뉴가 수정되었습니다.');
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
// 메뉴 목록 행 드래그 정렬 (마우스 이벤트 기반)
|
||||
|
||||
Reference in New Issue
Block a user