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'))
|
return redirect()->to(base_url('admin/select-local-government'))
|
||||||
->with('error', '지자체를 선택하세요.');
|
->with('error', '지자체를 선택하세요.');
|
||||||
}
|
}
|
||||||
$ids = $this->request->getPost('mm_idx');
|
|
||||||
$postMtIdx = (int) $this->request->getPost('mt_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)) {
|
if (! is_array($ids) || empty($ids)) {
|
||||||
return $this->menusRedirect($postMtIdx)->with('error', '순서를 적용할 메뉴가 없습니다.');
|
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 결정 (동일 지자체·메뉴종류·부모·깊이 기준)
|
* 추가 시 같은 레벨에서 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') ?>">
|
<form id="menu-move-form" method="post" action="<?= base_url('admin/menus/move') ?>">
|
||||||
<?= csrf_field() ?>
|
<?= csrf_field() ?>
|
||||||
<input type="hidden" name="mt_idx" value="<?= $mtIdx ?>"/>
|
<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">
|
<table class="data-table w-full">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<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 ?>">
|
<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">
|
<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>
|
||||||
<td class="text-center">
|
<td class="text-center">
|
||||||
<input type="hidden" name="mm_idx[]" value="<?= (int) $row->mm_idx ?>"/>
|
<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() {
|
const clearDragState = function() {
|
||||||
if (rafId) {
|
if (rafId) {
|
||||||
window.cancelAnimationFrame(rafId);
|
window.cancelAnimationFrame(rafId);
|
||||||
rafId = null;
|
rafId = null;
|
||||||
}
|
}
|
||||||
|
if (autoScrollRaf) {
|
||||||
|
window.cancelAnimationFrame(autoScrollRaf);
|
||||||
|
autoScrollRaf = null;
|
||||||
|
}
|
||||||
document.body.classList.remove('menu-row-dragging');
|
document.body.classList.remove('menu-row-dragging');
|
||||||
window.removeEventListener('mousemove', onMouseMove);
|
window.removeEventListener('mousemove', onMouseMove);
|
||||||
window.removeEventListener('mouseup', onMouseUp);
|
window.removeEventListener('mouseup', onMouseUp);
|
||||||
@@ -463,41 +496,76 @@ $adminMenuListResolveHref = static function (string $rawLink) use ($menuListReso
|
|||||||
draggingActive = false;
|
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() {
|
var onMouseUp = function() {
|
||||||
if (!draggingActive || !draggingRow || !placeholderRow) {
|
if (!draggingActive || !draggingRow || !placeholderRow) {
|
||||||
clearDragState();
|
clearDragState();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
var prevRow = placeholderRow.previousElementSibling;
|
|
||||||
tbody.insertBefore(draggingRow, placeholderRow);
|
tbody.insertBefore(draggingRow, placeholderRow);
|
||||||
refreshOrderNos();
|
refreshOrderNos();
|
||||||
|
|
||||||
var draggedDep = parseInt(draggingRow.dataset.mmDep || '0', 10);
|
var draggedDep = parseInt(draggingRow.dataset.mmDep || '0', 10);
|
||||||
var draggedPidx = parseInt(draggingRow.dataset.mmPidx || '0', 10);
|
|
||||||
|
|
||||||
if (draggedDep > 0) {
|
if (draggedDep > 0) {
|
||||||
var valid = false;
|
// 하위 메뉴: 놓인 위치의 상위 메뉴를 새 부모로 재지정 (다른 상위 메뉴로 이동 허용)
|
||||||
if (prevRow && prevRow.classList.contains('menu-row')) {
|
var newPidx = resolveNewParentIdx(draggingRow, draggedDep);
|
||||||
var prevIdx = parseInt(prevRow.dataset.mmIdx || '0', 10);
|
if (newPidx <= 0) {
|
||||||
var prevPidx = parseInt(prevRow.dataset.mmPidx || '0', 10);
|
alert('하위 메뉴는 상위 메뉴 아래에만 놓을 수 있습니다.');
|
||||||
if (prevRow === draggingRow) {
|
|
||||||
valid = true;
|
|
||||||
} else if (prevIdx === draggedPidx || prevPidx === draggedPidx) {
|
|
||||||
valid = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!valid) {
|
|
||||||
alert('하위 메뉴는 다른 상위 메뉴에는 들어갈 수 없습니다.');
|
|
||||||
restoreOrderByIds(originalOrderIds);
|
restoreOrderByIds(originalOrderIds);
|
||||||
clearDragState();
|
clearDragState();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
draggingRow.dataset.mmPidx = String(newPidx);
|
||||||
}
|
}
|
||||||
|
|
||||||
const approved = window.confirm('변경한 순서를 적용할까요?');
|
const approved = window.confirm('변경한 순서를 적용할까요?');
|
||||||
if (!approved) {
|
if (!approved) {
|
||||||
|
// 취소 시 원래 순서·상위로 되돌림
|
||||||
|
if (typeof draggingRow.dataset.mmPidxOrig !== 'undefined') {
|
||||||
|
draggingRow.dataset.mmPidx = draggingRow.dataset.mmPidxOrig;
|
||||||
|
}
|
||||||
restoreOrderByIds(originalOrderIds);
|
restoreOrderByIds(originalOrderIds);
|
||||||
} else {
|
} else {
|
||||||
|
if (payloadInput) payloadInput.value = JSON.stringify(buildTreePayload());
|
||||||
moveForm.submit();
|
moveForm.submit();
|
||||||
}
|
}
|
||||||
clearDragState();
|
clearDragState();
|
||||||
@@ -511,6 +579,8 @@ $adminMenuListResolveHref = static function (string $rawLink) use ($menuListReso
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
originalOrderIds = collectCurrentOrderIds();
|
originalOrderIds = collectCurrentOrderIds();
|
||||||
draggingRow = row;
|
draggingRow = row;
|
||||||
|
// 취소 시 되돌리기 위해 원래 상위(부모) 보관
|
||||||
|
row.dataset.mmPidxOrig = row.dataset.mmPidx || '0';
|
||||||
placeholderRow = makePlaceholder();
|
placeholderRow = makePlaceholder();
|
||||||
draggingActive = true;
|
draggingActive = true;
|
||||||
row.classList.add('dragging');
|
row.classList.add('dragging');
|
||||||
@@ -518,6 +588,7 @@ $adminMenuListResolveHref = static function (string $rawLink) use ($menuListReso
|
|||||||
tbody.insertBefore(placeholderRow, row.nextSibling);
|
tbody.insertBefore(placeholderRow, row.nextSibling);
|
||||||
lastClientY = e.clientY;
|
lastClientY = e.clientY;
|
||||||
updatePlaceholderPosition();
|
updatePlaceholderPosition();
|
||||||
|
autoScrollRaf = window.requestAnimationFrame(autoScrollTick);
|
||||||
window.addEventListener('mousemove', onMouseMove, { passive: true });
|
window.addEventListener('mousemove', onMouseMove, { passive: true });
|
||||||
window.addEventListener('mouseup', onMouseUp);
|
window.addEventListener('mouseup', onMouseUp);
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user