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

@@ -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');
});
// 메뉴 목록 행 드래그 정렬 (마우스 이벤트 기반)