feat: 메뉴 관리 하위메뉴 다른 상위로 드래그 이동 + 가장자리 자동 스크롤
- 하위 메뉴를 다른 상위 메뉴 아래로 드래그해 재부모(상위 변경) 지원 - 순서+상위+깊이를 함께 저장하는 MenuModel::setStructure() 추가(자식수 재계산) - 드래그 중 화면 상/하단 근처에서 자동 스크롤 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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', '순서를 적용할 메뉴가 없습니다.');
|
||||
}
|
||||
|
||||
@@ -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 결정 (동일 지자체·메뉴종류·부모·깊이 기준)
|
||||
*/
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user