feat: 메뉴 관리 하위메뉴 다른 상위로 드래그 이동 + 가장자리 자동 스크롤

- 하위 메뉴를 다른 상위 메뉴 아래로 드래그해 재부모(상위 변경) 지원
- 순서+상위+깊이를 함께 저장하는 MenuModel::setStructure() 추가(자식수 재계산)
- 드래그 중 화면 상/하단 근처에서 자동 스크롤

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
taekyoungc
2026-07-06 18:25:29 +09:00
parent b3518f6777
commit ace0ce6296
3 changed files with 164 additions and 16 deletions

View File

@@ -256,8 +256,31 @@ class Menu extends BaseController
return redirect()->to(base_url('admin/select-local-government'))
->with('error', '지자체를 선택하세요.');
}
$ids = $this->request->getPost('mm_idx');
$postMtIdx = (int) $this->request->getPost('mt_idx');
// 순서 + 상위(부모) 변경 구조가 함께 전달되면 우선 처리 (하위 메뉴의 상위 이동 지원)
$treeJson = (string) $this->request->getPost('menu_tree');
$structure = $treeJson !== '' ? json_decode($treeJson, true) : null;
if (is_array($structure) && $structure !== []) {
$orderedIds = array_values(array_filter(array_map(
static fn ($it): int => (int) ($it['idx'] ?? 0),
$structure
), static fn (int $v): bool => $v > 0));
if (empty($orderedIds)) {
return $this->menusRedirect($postMtIdx)->with('error', '순서를 적용할 메뉴가 없습니다.');
}
$firstRow = $this->menuModel->find($orderedIds[0]);
$this->menuModel->setStructure($structure, $lgIdx);
if ($firstRow && (int) $firstRow->lg_idx === $lgIdx) {
$this->menuModel->pruneInventoryManagementMenus((int) $firstRow->mt_idx, $lgIdx);
$this->menuModel->syncTypeToAllLgs((int) $firstRow->mt_idx, $lgIdx);
}
$mtIdx = $firstRow ? (int) $firstRow->mt_idx : $postMtIdx;
return $this->menusRedirect($mtIdx)->with('success', '순서가 적용되었습니다.');
}
$ids = $this->request->getPost('mm_idx');
if (! is_array($ids) || empty($ids)) {
return $this->menusRedirect($postMtIdx)->with('error', '순서를 적용할 메뉴가 없습니다.');
}

View File

@@ -82,6 +82,60 @@ class MenuModel extends Model
}
}
/**
* 순서 + 상위(부모)·깊이 변경을 함께 반영한다.
* 하위 메뉴를 다른 상위 메뉴로 옮기는 드래그를 지원하기 위한 용도.
*
* @param array<int,array{idx:int,pidx:int,dep:int}> $items 화면 표시 순서대로 정렬된 구조 배열
*/
public function setStructure(array $items, int $lgIdx): void
{
$affectedTypes = [];
$i = 0;
foreach ($items as $it) {
$mmIdx = (int) ($it['idx'] ?? 0);
if ($mmIdx <= 0) {
continue;
}
$row = $this->find($mmIdx);
if (! $row || (int) $row->lg_idx !== $lgIdx) {
continue;
}
$this->update($mmIdx, [
'mm_num' => $i,
'mm_pidx' => max(0, (int) ($it['pidx'] ?? 0)),
'mm_dep' => max(0, (int) ($it['dep'] ?? 0)),
]);
$affectedTypes[(int) $row->mt_idx] = true;
$i++;
}
// 상위가 바뀌었을 수 있으므로 종류별로 mm_cnode(자식 수)를 재계산
foreach (array_keys($affectedTypes) as $mtIdx) {
$this->recountChildNodes((int) $mtIdx, $lgIdx);
}
}
/**
* 특정 메뉴 종류·지자체의 각 메뉴에 대해 mm_cnode(직속 자식 수)를 실제 값으로 재계산한다.
*/
private function recountChildNodes(int $mtIdx, int $lgIdx): void
{
$rows = $this->where('mt_idx', $mtIdx)->where('lg_idx', $lgIdx)->findAll();
$childCount = [];
foreach ($rows as $r) {
$pid = (int) $r->mm_pidx;
if ($pid > 0) {
$childCount[$pid] = ($childCount[$pid] ?? 0) + 1;
}
}
foreach ($rows as $r) {
$want = (int) ($childCount[(int) $r->mm_idx] ?? 0);
if ((int) $r->mm_cnode !== $want) {
$this->update((int) $r->mm_idx, ['mm_cnode' => $want]);
}
}
}
/**
* 추가 시 같은 레벨에서 mm_num 결정 (동일 지자체·메뉴종류·부모·깊이 기준)
*/

View File

@@ -72,6 +72,7 @@ $adminMenuListResolveHref = static function (string $rawLink) use ($menuListReso
<form id="menu-move-form" method="post" action="<?= base_url('admin/menus/move') ?>">
<?= csrf_field() ?>
<input type="hidden" name="mt_idx" value="<?= $mtIdx ?>"/>
<input type="hidden" name="menu_tree" id="menu-tree-payload" value=""/>
<table class="data-table w-full">
<thead>
<tr>
@@ -92,7 +93,7 @@ $adminMenuListResolveHref = static function (string $rawLink) use ($menuListReso
?>
<tr class="menu-row" data-mm-idx="<?= (int) $row->mm_idx ?>" data-mm-pidx="<?= (int) $row->mm_pidx ?>" data-mm-dep="<?= (int) $row->mm_dep ?>">
<td class="text-center align-middle">
<span class="menu-drag-handle cursor-move text-gray-400 select-none" title="드래그해서 순서 변경하세요">↕</span>
<span class="menu-drag-handle cursor-move text-gray-400 select-none" title="드래그해서 순서 변경 · 하위 메뉴는 다른 상위 메뉴 아래로 이동 가능">↕</span>
</td>
<td class="text-center">
<input type="hidden" name="mm_idx[]" value="<?= (int) $row->mm_idx ?>"/>
@@ -444,11 +445,43 @@ $adminMenuListResolveHref = static function (string $rawLink) use ($menuListReso
}
};
// ── 드래그 중 화면 상/하단 가장자리 근처에서 자동 스크롤 ──────────────
// 스크롤되는 컨테이너(.main-content-area)가 있으면 그걸, 없으면 window를 스크롤.
let autoScrollRaf = null;
const getScrollContainer = function() {
const main = tbody.closest('.main-content-area');
if (main && main.scrollHeight > main.clientHeight + 2) return main;
return null; // null이면 window 스크롤
};
const autoScrollTick = function() {
if (!draggingActive) { autoScrollRaf = null; return; }
const EDGE = 70; // 가장자리 감지 영역(px)
const STEP_MAX = 20; // 프레임당 최대 스크롤(px)
const vh = window.innerHeight;
let dy = 0;
if (lastClientY < EDGE) {
dy = -Math.ceil((EDGE - lastClientY) / EDGE * STEP_MAX);
} else if (lastClientY > vh - EDGE) {
dy = Math.ceil((lastClientY - (vh - EDGE)) / EDGE * STEP_MAX);
}
if (dy !== 0) {
const sc = getScrollContainer();
if (sc) { sc.scrollTop += dy; } else { window.scrollBy(0, dy); }
// 스크롤로 행 위치가 바뀌었으니 placeholder 위치 재계산
if (!rafId) rafId = window.requestAnimationFrame(updatePlaceholderPosition);
}
autoScrollRaf = window.requestAnimationFrame(autoScrollTick);
};
const clearDragState = function() {
if (rafId) {
window.cancelAnimationFrame(rafId);
rafId = null;
}
if (autoScrollRaf) {
window.cancelAnimationFrame(autoScrollRaf);
autoScrollRaf = null;
}
document.body.classList.remove('menu-row-dragging');
window.removeEventListener('mousemove', onMouseMove);
window.removeEventListener('mouseup', onMouseUp);
@@ -463,41 +496,76 @@ $adminMenuListResolveHref = static function (string $rawLink) use ($menuListReso
draggingActive = false;
};
// 드래그된 행(하위 메뉴)의 새 상위 메뉴를 결정한다.
// 평면 목록에서 바로 위쪽으로 올라가며 dep이 (자신-1)인 가장 가까운 행을 상위로 삼는다.
const resolveNewParentIdx = function(row, draggedDep) {
var prev = row.previousElementSibling;
while (prev) {
if (prev.classList.contains('menu-row')) {
var pdep = parseInt(prev.dataset.mmDep || '0', 10);
if (pdep === draggedDep - 1) {
return parseInt(prev.dataset.mmIdx || '0', 10);
}
if (pdep < draggedDep - 1) {
return 0; // 유효한 상위가 없음
}
}
prev = prev.previousElementSibling;
}
return 0;
};
// 현재 화면 순서 그대로 구조(순서/상위/깊이) 페이로드를 만든다.
const buildTreePayload = function() {
var arr = [];
tbody.querySelectorAll('tr.menu-row').forEach(function(row){
var idInput = row.querySelector('input[name="mm_idx[]"]');
if (!idInput) return;
arr.push({
idx: parseInt(idInput.value, 10),
pidx: parseInt(row.dataset.mmPidx || '0', 10),
dep: parseInt(row.dataset.mmDep || '0', 10)
});
});
return arr;
};
const payloadInput = document.getElementById('menu-tree-payload');
// 수동 "순서 적용" 버튼 제출 시에도 구조가 반영되도록 payload 채움
moveForm.addEventListener('submit', function(){
if (payloadInput) payloadInput.value = JSON.stringify(buildTreePayload());
});
var onMouseUp = function() {
if (!draggingActive || !draggingRow || !placeholderRow) {
clearDragState();
return;
}
var prevRow = placeholderRow.previousElementSibling;
tbody.insertBefore(draggingRow, placeholderRow);
refreshOrderNos();
var draggedDep = parseInt(draggingRow.dataset.mmDep || '0', 10);
var draggedPidx = parseInt(draggingRow.dataset.mmPidx || '0', 10);
if (draggedDep > 0) {
var valid = false;
if (prevRow && prevRow.classList.contains('menu-row')) {
var prevIdx = parseInt(prevRow.dataset.mmIdx || '0', 10);
var prevPidx = parseInt(prevRow.dataset.mmPidx || '0', 10);
if (prevRow === draggingRow) {
valid = true;
} else if (prevIdx === draggedPidx || prevPidx === draggedPidx) {
valid = true;
}
}
if (!valid) {
alert('하위 메뉴는 다른 상위 메뉴에는 들어갈 수 없습니다.');
// 하위 메뉴: 놓인 위치의 상위 메뉴를 새 부모로 재지정 (다른 상위 메뉴로 이동 허용)
var newPidx = resolveNewParentIdx(draggingRow, draggedDep);
if (newPidx <= 0) {
alert('하위 메뉴는 상위 메뉴 아래에만 놓을 수 있습니다.');
restoreOrderByIds(originalOrderIds);
clearDragState();
return;
}
draggingRow.dataset.mmPidx = String(newPidx);
}
const approved = window.confirm('변경한 순서를 적용할까요?');
if (!approved) {
// 취소 시 원래 순서·상위로 되돌림
if (typeof draggingRow.dataset.mmPidxOrig !== 'undefined') {
draggingRow.dataset.mmPidx = draggingRow.dataset.mmPidxOrig;
}
restoreOrderByIds(originalOrderIds);
} else {
if (payloadInput) payloadInput.value = JSON.stringify(buildTreePayload());
moveForm.submit();
}
clearDragState();
@@ -511,6 +579,8 @@ $adminMenuListResolveHref = static function (string $rawLink) use ($menuListReso
e.preventDefault();
originalOrderIds = collectCurrentOrderIds();
draggingRow = row;
// 취소 시 되돌리기 위해 원래 상위(부모) 보관
row.dataset.mmPidxOrig = row.dataset.mmPidx || '0';
placeholderRow = makePlaceholder();
draggingActive = true;
row.classList.add('dragging');
@@ -518,6 +588,7 @@ $adminMenuListResolveHref = static function (string $rawLink) use ($menuListReso
tbody.insertBefore(placeholderRow, row.nextSibling);
lastClientY = e.clientY;
updatePlaceholderPosition();
autoScrollRaf = window.requestAnimationFrame(autoScrollTick);
window.addEventListener('mousemove', onMouseMove, { passive: true });
window.addEventListener('mouseup', onMouseUp);
});