- 등록/수정 폼에 상위/하위 선택(상위 메뉴 지정), 기존 메뉴도 상위↔하위 이동 가능 - 하위 보유 메뉴의 하위 변경 차단 등 검증 - syncTypeToAllLgs를 dep별 batch insert로 재작성 → 메뉴 저장 시 원격 DB 왕복 급감(30초 타임아웃 해소) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
684 lines
28 KiB
PHP
684 lines
28 KiB
PHP
<?php
|
|
$types = $types ?? [];
|
|
$list = $list ?? [];
|
|
$mtIdx = (int) ($mtIdx ?? 0);
|
|
$mtCode = (string) ($mtCode ?? '');
|
|
$levelNames = $levelNames ?? [];
|
|
$debugMode = (bool) ($debug_mode ?? false);
|
|
$debugInfo = is_array($debug_info ?? null) ? $debug_info : [];
|
|
helper('admin');
|
|
$adminMenusNavPath = current_nav_request_path();
|
|
// 사이트 메뉴(mt_code=site)는 업무 URL이 /bag/* — 관리 화면(admin/menus) 맥락으로 해석하면 admin/ 링크가 잘못 선택됨
|
|
$menuListResolvePath = ($mtCode === 'site') ? 'bag/dashboard' : $adminMenusNavPath;
|
|
|
|
/**
|
|
* 메뉴 관리 목록용: 저장된 mm_link → 실제 href (외부 http(s) 또는 base_url).
|
|
*/
|
|
$adminMenuListResolveHref = static function (string $rawLink) use ($menuListResolvePath): string {
|
|
$rawLink = trim($rawLink);
|
|
if ($rawLink === '') {
|
|
return '';
|
|
}
|
|
if (preg_match('#^https?://#i', $rawLink)) {
|
|
return $rawLink;
|
|
}
|
|
$pathSeg = menu_link_preferred_href_path($rawLink, $menuListResolvePath);
|
|
if ($pathSeg === '') {
|
|
$pathSeg = normalize_menu_link_for_url($rawLink);
|
|
}
|
|
|
|
return $pathSeg !== '' ? base_url($pathSeg) : '';
|
|
};
|
|
?>
|
|
<section class="border-b border-gray-300 p-2 shrink-0 bg-control-panel">
|
|
<div class="flex flex-wrap items-center justify-between gap-y-2">
|
|
<span class="text-sm font-bold text-gray-700">메뉴 관리</span>
|
|
<div class="flex items-center gap-2">
|
|
<?php if (! empty($types)): ?>
|
|
<form method="get" action="<?= base_url('admin/menus') ?>" class="flex items-center gap-2">
|
|
<label class="text-sm font-medium text-gray-700">메뉴 종류</label>
|
|
<select name="mt_idx" onchange="this.form.submit()" class="border border-gray-300 rounded pl-2 pr-7 py-1 text-sm min-w-[8rem]">
|
|
<?php foreach ($types as $t): ?>
|
|
<option value="<?= (int) $t->mt_idx ?>" <?= $mtIdx === (int) $t->mt_idx ? 'selected' : '' ?>><?= esc($t->mt_name) ?></option>
|
|
<?php endforeach; ?>
|
|
</select>
|
|
</form>
|
|
<?php endif; ?>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<?php if ($debugMode): ?>
|
|
<section class="mt-2 border border-amber-300 bg-amber-50 text-amber-900 rounded p-2 text-xs">
|
|
<strong>[DEBUG]</strong>
|
|
lg_idx=<?= esc((string) ($debugInfo['lg_idx'] ?? '')) ?>,
|
|
requested_mt_idx=<?= esc((string) ($debugInfo['requested_mt_idx'] ?? '')) ?>,
|
|
resolved_mt_idx=<?= esc((string) ($debugInfo['resolved_mt_idx'] ?? '')) ?>,
|
|
effective_mt_idx=<?= esc((string) ($debugInfo['effective_mt_idx'] ?? '')) ?>,
|
|
resolved_mt_code=<?= esc((string) ($debugInfo['resolved_mt_code'] ?? '')) ?>,
|
|
list_count=<?= esc((string) ($debugInfo['list_count'] ?? '')) ?>,
|
|
fallback_applied=<?= esc((string) ($debugInfo['fallback_applied'] ?? 'N')) ?>
|
|
</section>
|
|
<?php endif; ?>
|
|
|
|
<div class="flex gap-4 mt-2 flex-wrap">
|
|
<div class="border border-gray-300 bg-white rounded p-4 flex-1 min-w-0" style="min-width: 320px;">
|
|
<h3 class="text-sm font-bold text-gray-700 mb-2">메뉴 목록</h3>
|
|
<?php if ($mtIdx <= 0): ?>
|
|
<p class="text-sm text-gray-600">메뉴 종류를 선택하세요.</p>
|
|
<?php elseif (empty($list)): ?>
|
|
<p class="text-sm text-gray-600">등록된 메뉴가 없습니다. 아래에서 등록하세요.</p>
|
|
<?php else: ?>
|
|
<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>
|
|
<th class="w-20 text-center text-xs font-medium text-gray-600">순서변경</th>
|
|
<th class="w-10 text-center">#</th>
|
|
<th class="text-left">메뉴명</th>
|
|
<th class="text-left">링크</th>
|
|
<th class="text-left">노출 대상</th>
|
|
<th class="text-center">사용</th>
|
|
<th class="w-24 text-center">작업</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<?php foreach ($list as $i => $row): ?>
|
|
<?php
|
|
$rawLink = trim((string) $row->mm_link);
|
|
$listItemHref = $rawLink !== '' ? $adminMenuListResolveHref($rawLink) : '';
|
|
?>
|
|
<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>
|
|
</td>
|
|
<td class="text-center">
|
|
<input type="hidden" name="mm_idx[]" value="<?= (int) $row->mm_idx ?>"/>
|
|
<span class="menu-order-no"><?= (int) $row->mm_num + 1 ?></span>
|
|
</td>
|
|
<td class="text-left pl-2" style="padding-left: <?= (int) $row->mm_dep * 12 + 8 ?>px;">
|
|
<?php $dep = (int) $row->mm_dep; ?>
|
|
<span class="text-xs text-gray-400">
|
|
<?php if ($dep === 0): ?>
|
|
●
|
|
<?php elseif ($dep === 1): ?>
|
|
└
|
|
<?php else: ?>
|
|
└─
|
|
<?php endif; ?>
|
|
</span>
|
|
<?php if ($listItemHref !== ''): ?>
|
|
<a href="<?= esc($listItemHref) ?>" class="ml-1 text-gray-900 hover:text-blue-700 hover:underline font-medium" target="_blank" rel="noopener noreferrer"><?= esc($row->mm_name) ?></a>
|
|
<?php else: ?>
|
|
<span class="ml-1"><?= esc($row->mm_name) ?></span>
|
|
<?php endif; ?>
|
|
</td>
|
|
<td class="text-left pl-2 text-xs">
|
|
<?php if ($rawLink === ''): ?>
|
|
<span class="text-gray-400">—</span>
|
|
<?php elseif ($listItemHref !== ''): ?>
|
|
<a href="<?= esc($listItemHref) ?>" class="text-blue-600 hover:underline font-medium break-all" target="_blank" rel="noopener noreferrer"><?= esc($rawLink) ?></a>
|
|
<?php else: ?>
|
|
<span class="text-amber-700" title="URL로 해석되지 않는 링크입니다"><?= esc($rawLink) ?></span>
|
|
<?php endif; ?>
|
|
</td>
|
|
<td class="text-left pl-2 text-xs">
|
|
<?php
|
|
if ((string) $row->mm_level === '') {
|
|
echo '전체';
|
|
} else {
|
|
$levels = array_filter(explode(',', $row->mm_level), fn ($lv) => ! \Config\Roles::isSuperAdminEquivalent((int) trim($lv)));
|
|
$labels = array_map(fn ($lv) => $levelNames[trim($lv)] ?? trim($lv), $levels);
|
|
echo esc(implode(', ', $labels) ?: '전체');
|
|
}
|
|
?>
|
|
</td>
|
|
<td class="text-center"><?= (string) $row->mm_is_view === 'Y' ? 'Y' : 'N' ?></td>
|
|
<td class="text-center">
|
|
<button type="button"
|
|
class="text-blue-600 hover:underline text-sm menu-edit"
|
|
data-id="<?= (int) $row->mm_idx ?>"
|
|
data-name="<?= esc($row->mm_name) ?>"
|
|
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-pidx="<?= (int) $row->mm_pidx ?>">
|
|
수정
|
|
</button>
|
|
<?php if ($dep === 0): ?>
|
|
<button type="button"
|
|
class="text-green-700 hover:underline text-sm ml-1 menu-add-child"
|
|
data-id="<?= (int) $row->mm_idx ?>"
|
|
data-dep="<?= (int) $row->mm_dep ?>">
|
|
하위 메뉴 추가
|
|
</button>
|
|
<?php endif; ?>
|
|
<form action="<?= base_url('admin/menus/delete/' . (int) $row->mm_idx) ?>" method="post" class="inline ml-1" onsubmit="return confirm('이 메뉴를 삭제하시겠습니까?');">
|
|
<?= csrf_field() ?>
|
|
<input type="hidden" name="mt_idx" value="<?= $mtIdx ?>"/>
|
|
<button type="submit" class="text-red-600 hover:underline text-sm">삭제</button>
|
|
</form>
|
|
</td>
|
|
</tr>
|
|
<?php endforeach; ?>
|
|
</tbody>
|
|
</table>
|
|
<div class="mt-2">
|
|
<button type="submit" class="bg-gray-600 text-white px-3 py-1 rounded text-sm">순서 적용</button>
|
|
</div>
|
|
</form>
|
|
<?php endif; ?>
|
|
</div>
|
|
|
|
<div class="border border-gray-300 bg-white rounded p-4 w-80 shrink-0">
|
|
<h3 class="text-sm font-bold text-gray-700 mb-2" id="form-title">메뉴 등록</h3>
|
|
<form id="menu-form" method="post" action="<?= base_url('admin/menus/store') ?>">
|
|
<?= csrf_field() ?>
|
|
<input type="hidden" name="mt_idx" value="<?= $mtIdx ?>"/>
|
|
<input type="hidden" name="mm_pidx" value="0"/>
|
|
<input type="hidden" name="mm_dep" value="0"/>
|
|
<input type="hidden" name="mm_idx_edit" id="mm_idx_edit" value=""/>
|
|
|
|
<div class="space-y-2 mb-3">
|
|
<label class="block text-sm font-medium text-gray-700">메뉴명</label>
|
|
<input type="text" name="mm_name" id="mm_name" required class="border border-gray-300 rounded px-2 py-1 w-full text-sm" placeholder="예: 대시보드"/>
|
|
</div>
|
|
<div class="space-y-2 mb-3">
|
|
<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'): ?>
|
|
<p class="text-sm text-gray-600">관리자 메뉴는 <b>지자체관리자</b>에게만 노출됩니다. (고정)</p>
|
|
<?php else: ?>
|
|
<div class="flex flex-wrap gap-x-4 gap-y-1">
|
|
<label class="inline-flex items-center gap-1">
|
|
<input type="checkbox" name="mm_level_all" id="mm_level_all" value="1" checked/>
|
|
<span class="text-sm">전체</span>
|
|
</label>
|
|
<?php foreach ($levelNames as $lv => $name): ?>
|
|
<?php if (\Config\Roles::isSuperAdminEquivalent((int) $lv)) { continue; } ?>
|
|
<label class="inline-flex items-center gap-1 mm-level-label">
|
|
<input type="checkbox" name="mm_level[]" value="<?= (int) $lv ?>" class="mm-level-cb"/>
|
|
<span class="text-sm"><?= esc($name) ?></span>
|
|
</label>
|
|
<?php endforeach; ?>
|
|
</div>
|
|
<?php endif; ?>
|
|
</div>
|
|
<div class="space-y-2 mb-3">
|
|
<label class="inline-flex items-center gap-1">
|
|
<input type="checkbox" name="mm_is_view" value="1" id="mm_is_view" checked/>
|
|
<span class="text-sm">노출</span>
|
|
</label>
|
|
</div>
|
|
<div class="flex gap-2">
|
|
<button type="submit" class="bg-btn-search text-white px-4 py-1.5 rounded text-sm" id="btn-submit">등록</button>
|
|
<button type="button" class="bg-gray-200 text-gray-700 px-4 py-1.5 rounded text-sm" id="btn-cancel-edit" style="display:none;">취소</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
|
|
<style>
|
|
tr.menu-row.dragging {
|
|
opacity: 0.35;
|
|
}
|
|
body.menu-row-dragging {
|
|
user-select: none;
|
|
cursor: move;
|
|
}
|
|
tr.menu-drop-placeholder td {
|
|
padding: 0;
|
|
border: 0;
|
|
}
|
|
.menu-drop-placeholder-box {
|
|
margin: 4px 0;
|
|
height: 34px;
|
|
border: 2px dashed #60a5fa;
|
|
border-radius: 6px;
|
|
background: #eff6ff;
|
|
color: #1d4ed8;
|
|
font-size: 12px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
}
|
|
</style>
|
|
|
|
<script>
|
|
(function(){
|
|
const form = document.getElementById('menu-form');
|
|
const formTitle = document.getElementById('form-title');
|
|
const btnSubmit = document.getElementById('btn-submit');
|
|
const btnCancel = document.getElementById('btn-cancel-edit');
|
|
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';
|
|
const moveForm = document.getElementById('menu-move-form');
|
|
const tbody = moveForm ? moveForm.querySelector('tbody') : null;
|
|
let draggingRow = null;
|
|
|
|
if (!isAdminType && levelAll) {
|
|
// "전체" 체크 시: 다른 체크 해제
|
|
levelAll.addEventListener('change', function(){
|
|
if (levelAll.checked) {
|
|
levelCbs.forEach(function(cb){ cb.checked = false; });
|
|
}
|
|
});
|
|
|
|
// 다른 체크 선택 시: "전체" 자동 해제
|
|
levelCbs.forEach(function(cb){
|
|
cb.addEventListener('change', function(){
|
|
if (cb.checked) levelAll.checked = false;
|
|
});
|
|
});
|
|
|
|
form.addEventListener('submit', function(){
|
|
if (!levelAll.checked) {
|
|
var checked = [];
|
|
levelCbs.forEach(function(cb){ if (cb.checked) checked.push(cb.value); });
|
|
if (checked.length === 0) levelAll.checked = true;
|
|
}
|
|
});
|
|
}
|
|
|
|
document.querySelectorAll('.menu-edit').forEach(function(btn){
|
|
btn.addEventListener('click', function(){
|
|
const id = this.dataset.id;
|
|
const name = this.dataset.name;
|
|
const link = this.dataset.link || '';
|
|
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;
|
|
// 위치(상위/하위) 선택 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){
|
|
cb.checked = level !== '' && level.split(',').indexOf(cb.value) >= 0;
|
|
cb.setAttribute('name', 'mm_level[]');
|
|
});
|
|
if (level !== '' && !Array.prototype.some.call(levelCbs, function(cb){ return cb.checked; })) {
|
|
levelAll.checked = true;
|
|
levelCbs.forEach(function(cb){ cb.checked = false; });
|
|
}
|
|
}
|
|
form.querySelector('[name="mm_is_view"]').checked = (view === 'Y');
|
|
editIdInput.value = id;
|
|
formTitle.textContent = '메뉴 수정';
|
|
btnSubmit.textContent = '수정';
|
|
btnCancel.style.display = 'inline-block';
|
|
// 수정 폼이 보이도록 스크롤 최상단으로 이동
|
|
window.scrollTo({ top: 0, behavior: 'smooth' });
|
|
const sc = document.querySelector('.main-content-area');
|
|
if (sc) sc.scrollTo({ top: 0, behavior: 'smooth' });
|
|
});
|
|
});
|
|
|
|
// 하위 메뉴 추가
|
|
document.querySelectorAll('.menu-add-child').forEach(function(btn){
|
|
btn.addEventListener('click', function(){
|
|
const parentId = this.dataset.id;
|
|
const parentDep = parseInt(this.dataset.dep || '0', 10);
|
|
form.action = '<?= base_url('admin/menus/store') ?>';
|
|
form.reset();
|
|
form.querySelector('[name="mt_idx"]').value = '<?= $mtIdx ?>';
|
|
mmPidxInput.value = parentId;
|
|
mmDepInput.value = String(parentDep + 1);
|
|
editIdInput.value = '';
|
|
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) {
|
|
levelAll.checked = true;
|
|
levelCbs.forEach(function(cb){
|
|
cb.checked = false;
|
|
cb.setAttribute('name', 'mm_level[]');
|
|
});
|
|
}
|
|
document.getElementById('mm_name').focus();
|
|
});
|
|
});
|
|
|
|
btnCancel.addEventListener('click', function(){
|
|
form.action = '<?= base_url('admin/menus/store') ?>';
|
|
form.reset();
|
|
form.querySelector('[name="mt_idx"]').value = '<?= $mtIdx ?>';
|
|
mmPidxInput.value = '0';
|
|
mmDepInput.value = '0';
|
|
form.querySelector('[name="mm_is_view"]').checked = true;
|
|
if (!isAdminType && levelAll) {
|
|
levelAll.checked = true;
|
|
levelCbs.forEach(function(cb){ cb.checked = false; cb.setAttribute('name', 'mm_level[]'); });
|
|
}
|
|
editIdInput.value = '';
|
|
formTitle.textContent = '메뉴 등록';
|
|
btnSubmit.textContent = '등록';
|
|
btnCancel.style.display = 'none';
|
|
// 위치 선택 UI 복원 → 기본 '상위'
|
|
if (posWrap) posWrap.style.display = '';
|
|
enableAllParentOptions();
|
|
setPosition('top');
|
|
});
|
|
|
|
// 메뉴 목록 행 드래그 정렬 (마우스 이벤트 기반)
|
|
if (tbody) {
|
|
const colCount = document.querySelectorAll('.data-table thead th').length || 7;
|
|
const makePlaceholder = function() {
|
|
const tr = document.createElement('tr');
|
|
tr.className = 'menu-drop-placeholder';
|
|
const td = document.createElement('td');
|
|
td.colSpan = colCount;
|
|
td.innerHTML = '<div class="menu-drop-placeholder-box">여기에 놓기</div>';
|
|
tr.appendChild(td);
|
|
return tr;
|
|
};
|
|
|
|
const refreshOrderNos = function() {
|
|
tbody.querySelectorAll('tr.menu-row').forEach(function(row, idx){
|
|
const noEl = row.querySelector('.menu-order-no');
|
|
if (noEl) noEl.textContent = String(idx + 1);
|
|
});
|
|
};
|
|
|
|
let placeholderRow = null;
|
|
let originalOrderIds = [];
|
|
let rafId = null;
|
|
let lastClientY = 0;
|
|
let draggingActive = false;
|
|
|
|
const collectCurrentOrderIds = function() {
|
|
return Array.prototype.slice.call(tbody.querySelectorAll('tr.menu-row')).map(function(row){
|
|
const idInput = row.querySelector('input[name="mm_idx[]"]');
|
|
return idInput ? idInput.value : '';
|
|
}).filter(Boolean);
|
|
};
|
|
|
|
const restoreOrderByIds = function(ids) {
|
|
if (!ids.length) return;
|
|
const rowMap = {};
|
|
tbody.querySelectorAll('tr.menu-row').forEach(function(row){
|
|
const idInput = row.querySelector('input[name="mm_idx[]"]');
|
|
if (idInput) rowMap[idInput.value] = row;
|
|
});
|
|
ids.forEach(function(id){
|
|
if (rowMap[id]) tbody.appendChild(rowMap[id]);
|
|
});
|
|
refreshOrderNos();
|
|
};
|
|
|
|
const updatePlaceholderPosition = function() {
|
|
rafId = null;
|
|
if (!draggingActive || !draggingRow || !placeholderRow) return;
|
|
const rows = Array.prototype.slice.call(tbody.querySelectorAll('tr.menu-row:not(.dragging)'));
|
|
let placed = false;
|
|
for (var i = 0; i < rows.length; i += 1) {
|
|
var box = rows[i].getBoundingClientRect();
|
|
var triggerY = box.top + (box.height * 0.25);
|
|
if (lastClientY < triggerY) {
|
|
if (placeholderRow !== rows[i]) {
|
|
tbody.insertBefore(placeholderRow, rows[i]);
|
|
}
|
|
placed = true;
|
|
break;
|
|
}
|
|
}
|
|
if (!placed) {
|
|
tbody.appendChild(placeholderRow);
|
|
}
|
|
};
|
|
|
|
const onMouseMove = function(e) {
|
|
if (!draggingActive) return;
|
|
lastClientY = e.clientY;
|
|
if (!rafId) {
|
|
rafId = window.requestAnimationFrame(updatePlaceholderPosition);
|
|
}
|
|
};
|
|
|
|
// ── 드래그 중 화면 상/하단 가장자리 근처에서 자동 스크롤 ──────────────
|
|
// 스크롤되는 컨테이너(.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);
|
|
if (draggingRow) {
|
|
draggingRow.classList.remove('dragging');
|
|
}
|
|
if (placeholderRow && placeholderRow.parentNode) {
|
|
placeholderRow.parentNode.removeChild(placeholderRow);
|
|
}
|
|
placeholderRow = null;
|
|
draggingRow = null;
|
|
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;
|
|
}
|
|
tbody.insertBefore(draggingRow, placeholderRow);
|
|
refreshOrderNos();
|
|
|
|
var draggedDep = parseInt(draggingRow.dataset.mmDep || '0', 10);
|
|
|
|
if (draggedDep > 0) {
|
|
// 하위 메뉴: 놓인 위치의 상위 메뉴를 새 부모로 재지정 (다른 상위 메뉴로 이동 허용)
|
|
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();
|
|
};
|
|
|
|
tbody.querySelectorAll('.menu-drag-handle').forEach(function(handle){
|
|
handle.addEventListener('mousedown', function(e){
|
|
if (e.button !== 0) return;
|
|
const row = handle.closest('tr.menu-row');
|
|
if (!row) return;
|
|
e.preventDefault();
|
|
originalOrderIds = collectCurrentOrderIds();
|
|
draggingRow = row;
|
|
// 취소 시 되돌리기 위해 원래 상위(부모) 보관
|
|
row.dataset.mmPidxOrig = row.dataset.mmPidx || '0';
|
|
placeholderRow = makePlaceholder();
|
|
draggingActive = true;
|
|
row.classList.add('dragging');
|
|
document.body.classList.add('menu-row-dragging');
|
|
tbody.insertBefore(placeholderRow, row.nextSibling);
|
|
lastClientY = e.clientY;
|
|
updatePlaceholderPosition();
|
|
autoScrollRaf = window.requestAnimationFrame(autoScrollTick);
|
|
window.addEventListener('mousemove', onMouseMove, { passive: true });
|
|
window.addEventListener('mouseup', onMouseUp);
|
|
});
|
|
});
|
|
}
|
|
})();
|
|
</script>
|