feat: [메뉴] 글로벌 메뉴 관리 기능 구현 (슈퍼관리자 전용)
- 글로벌 메뉴 CRUD API 및 라우트 추가 (GlobalMenuController) - 글로벌 메뉴 목록/생성/수정 뷰 추가 (보라색 테마) - MenuService에 글로벌 메뉴 관련 메서드 11개 추가 - 메뉴 관리 페이지에 '글로벌 메뉴 관리' 버튼 추가 - 가져오기 모드에서 이미 가져온 메뉴 비활성화 표시 - super.admin 미들웨어로 접근 제어
This commit is contained in:
296
app/Http/Controllers/Api/Admin/GlobalMenuController.php
Normal file
296
app/Http/Controllers/Api/Admin/GlobalMenuController.php
Normal file
@@ -0,0 +1,296 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\StoreMenuRequest;
|
||||
use App\Http\Requests\UpdateMenuRequest;
|
||||
use App\Services\MenuService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class GlobalMenuController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly MenuService $menuService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 글로벌 메뉴 목록 조회
|
||||
*/
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$menus = $this->menuService->getGlobalMenus(
|
||||
$request->all(),
|
||||
$request->integer('per_page', 100) // 글로벌 메뉴는 많지 않으므로 페이지당 100개
|
||||
);
|
||||
|
||||
// HTMX 요청인 경우 HTML 반환
|
||||
if ($request->header('HX-Request')) {
|
||||
$html = view('menus.partials.global-table', compact('menus'))->render();
|
||||
|
||||
return response()->json(['html' => $html]);
|
||||
}
|
||||
|
||||
// 일반 API 요청인 경우 JSON 반환
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $menus->items(),
|
||||
'meta' => [
|
||||
'current_page' => $menus->currentPage(),
|
||||
'last_page' => $menus->lastPage(),
|
||||
'per_page' => $menus->perPage(),
|
||||
'total' => $menus->total(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 글로벌 메뉴 상세 조회
|
||||
*/
|
||||
public function show(int $id): JsonResponse
|
||||
{
|
||||
$menu = $this->menuService->getGlobalMenuById($id);
|
||||
|
||||
if (! $menu) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '글로벌 메뉴를 찾을 수 없습니다.',
|
||||
], 404);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $menu,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 글로벌 메뉴 생성
|
||||
*/
|
||||
public function store(StoreMenuRequest $request): JsonResponse
|
||||
{
|
||||
try {
|
||||
$menu = $this->menuService->createGlobalMenu($request->validated());
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => '글로벌 메뉴가 생성되었습니다.',
|
||||
'data' => $menu,
|
||||
'redirect' => route('menus.global.index'),
|
||||
], 201);
|
||||
} catch (\Exception $e) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '글로벌 메뉴 생성에 실패했습니다: '.$e->getMessage(),
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 글로벌 메뉴 수정
|
||||
*/
|
||||
public function update(UpdateMenuRequest $request, int $id): JsonResponse
|
||||
{
|
||||
try {
|
||||
$result = $this->menuService->updateGlobalMenu($id, $request->validated());
|
||||
|
||||
if (! $result) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '글로벌 메뉴를 찾을 수 없거나 수정할 수 없습니다.',
|
||||
], 404);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => '글로벌 메뉴가 수정되었습니다.',
|
||||
'redirect' => route('menus.global.index'),
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '글로벌 메뉴 수정에 실패했습니다: '.$e->getMessage(),
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 글로벌 메뉴 삭제
|
||||
*/
|
||||
public function destroy(int $id): JsonResponse
|
||||
{
|
||||
try {
|
||||
$result = $this->menuService->deleteGlobalMenu($id);
|
||||
|
||||
if (! $result) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '글로벌 메뉴를 찾을 수 없거나 자식 메뉴가 있어 삭제할 수 없습니다.',
|
||||
], 404);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => '글로벌 메뉴가 삭제되었습니다.',
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '글로벌 메뉴 삭제에 실패했습니다: '.$e->getMessage(),
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 글로벌 메뉴 복원
|
||||
*/
|
||||
public function restore(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$this->menuService->restoreGlobalMenu($id);
|
||||
|
||||
if ($request->header('HX-Request')) {
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => '글로벌 메뉴가 복원되었습니다.',
|
||||
'action' => 'refresh',
|
||||
]);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => '글로벌 메뉴가 복원되었습니다.',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 글로벌 메뉴 영구 삭제
|
||||
*/
|
||||
public function forceDestroy(Request $request, int $id): JsonResponse
|
||||
{
|
||||
try {
|
||||
$result = $this->menuService->forceDeleteGlobalMenu($id);
|
||||
|
||||
if (! $result) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '글로벌 메뉴를 찾을 수 없거나 자식 메뉴가 있어 영구 삭제할 수 없습니다.',
|
||||
], 404);
|
||||
}
|
||||
|
||||
if ($request->header('HX-Request')) {
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => '글로벌 메뉴가 영구 삭제되었습니다.',
|
||||
'action' => 'refresh',
|
||||
]);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => '글로벌 메뉴가 영구 삭제되었습니다.',
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '글로벌 메뉴 영구 삭제에 실패했습니다: '.$e->getMessage(),
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 글로벌 메뉴 활성 상태 토글
|
||||
*/
|
||||
public function toggleActive(Request $request, int $id): JsonResponse
|
||||
{
|
||||
try {
|
||||
$result = $this->menuService->toggleGlobalActive($id);
|
||||
|
||||
if (! $result) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '글로벌 메뉴를 찾을 수 없습니다.',
|
||||
], 404);
|
||||
}
|
||||
|
||||
if ($request->header('HX-Request')) {
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => '글로벌 메뉴 활성 상태가 변경되었습니다.',
|
||||
'action' => 'refresh',
|
||||
]);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => '글로벌 메뉴 활성 상태가 변경되었습니다.',
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '글로벌 메뉴 활성 상태 변경에 실패했습니다: '.$e->getMessage(),
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 글로벌 메뉴 숨김 상태 토글
|
||||
*/
|
||||
public function toggleHidden(Request $request, int $id): JsonResponse
|
||||
{
|
||||
try {
|
||||
$result = $this->menuService->toggleGlobalHidden($id);
|
||||
|
||||
if (! $result) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '글로벌 메뉴를 찾을 수 없습니다.',
|
||||
], 404);
|
||||
}
|
||||
|
||||
if ($request->header('HX-Request')) {
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => '글로벌 메뉴 숨김 상태가 변경되었습니다.',
|
||||
'action' => 'refresh',
|
||||
]);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => '글로벌 메뉴 숨김 상태가 변경되었습니다.',
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '글로벌 메뉴 숨김 상태 변경에 실패했습니다: '.$e->getMessage(),
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 글로벌 메뉴 순서 변경
|
||||
*/
|
||||
public function reorder(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'items' => 'required|array',
|
||||
'items.*.id' => 'required|integer',
|
||||
'items.*.sort_order' => 'required|integer',
|
||||
]);
|
||||
|
||||
try {
|
||||
$this->menuService->reorderGlobalMenus($validated['items']);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => '글로벌 메뉴 순서가 변경되었습니다.',
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '글로벌 메뉴 순서 변경에 실패했습니다: '.$e->getMessage(),
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -24,8 +24,8 @@ public function index(Request $request): JsonResponse
|
||||
$importMode = $request->get('mode') === 'import' && $tenantId;
|
||||
|
||||
if ($importMode) {
|
||||
// 가져오기 모드: 복사 가능한 글로벌 메뉴 목록
|
||||
$menus = $this->menuService->getAvailableGlobalMenus($tenantId);
|
||||
// 가져오기 모드: 전체 글로벌 메뉴 (가져오기 상태 포함)
|
||||
$menus = $this->menuService->getAllGlobalMenusWithStatus($tenantId);
|
||||
} else {
|
||||
// 일반 모드: 현재 범위의 메뉴 목록
|
||||
$menus = $this->menuService->getMenus(
|
||||
@@ -369,8 +369,7 @@ public function move(Request $request): JsonResponse
|
||||
}
|
||||
|
||||
/**
|
||||
* 복사 가능한 글로벌 메뉴 목록 조회
|
||||
* (현재 테넌트에 존재하지 않는 글로벌 메뉴만)
|
||||
* 글로벌 메뉴 목록 조회 (가져오기 상태 포함)
|
||||
*/
|
||||
public function availableGlobal(Request $request): JsonResponse
|
||||
{
|
||||
@@ -385,7 +384,7 @@ public function availableGlobal(Request $request): JsonResponse
|
||||
}
|
||||
|
||||
try {
|
||||
$menus = $this->menuService->getAvailableGlobalMenus($tenantId);
|
||||
$menus = $this->menuService->getAllGlobalMenusWithStatus($tenantId);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
@@ -396,6 +395,7 @@ public function availableGlobal(Request $request): JsonResponse
|
||||
'url' => $menu->url,
|
||||
'icon' => $menu->icon,
|
||||
'depth' => $menu->depth ?? 0,
|
||||
'is_imported' => $menu->is_imported ?? false,
|
||||
];
|
||||
})->values(),
|
||||
]);
|
||||
|
||||
@@ -20,6 +20,53 @@ public function index(Request $request): View
|
||||
return view('menus.index');
|
||||
}
|
||||
|
||||
/**
|
||||
* 글로벌 메뉴 관리 페이지 (슈퍼관리자 전용)
|
||||
*/
|
||||
public function globalIndex(): View
|
||||
{
|
||||
// 슈퍼관리자 체크
|
||||
if (! auth()->user()?->is_super_admin) {
|
||||
abort(403, '접근 권한이 없습니다.');
|
||||
}
|
||||
|
||||
return view('menus.global-index');
|
||||
}
|
||||
|
||||
/**
|
||||
* 글로벌 메뉴 생성 페이지 (슈퍼관리자 전용)
|
||||
*/
|
||||
public function globalCreate(): View
|
||||
{
|
||||
if (! auth()->user()?->is_super_admin) {
|
||||
abort(403, '접근 권한이 없습니다.');
|
||||
}
|
||||
|
||||
$parentMenus = $this->menuService->getGlobalParentMenus();
|
||||
|
||||
return view('menus.global-create', compact('parentMenus'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 글로벌 메뉴 수정 페이지 (슈퍼관리자 전용)
|
||||
*/
|
||||
public function globalEdit(int $id): View
|
||||
{
|
||||
if (! auth()->user()?->is_super_admin) {
|
||||
abort(403, '접근 권한이 없습니다.');
|
||||
}
|
||||
|
||||
$menu = $this->menuService->getGlobalMenuById($id);
|
||||
|
||||
if (! $menu) {
|
||||
abort(404, '글로벌 메뉴를 찾을 수 없습니다.');
|
||||
}
|
||||
|
||||
$parentMenus = $this->menuService->getGlobalParentMenus();
|
||||
|
||||
return view('menus.global-edit', compact('menu', 'parentMenus'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 메뉴 생성 페이지
|
||||
*/
|
||||
|
||||
@@ -445,10 +445,223 @@ private function compactSiblings(?int $parentId): void
|
||||
}
|
||||
|
||||
/**
|
||||
* 복사 가능한 글로벌 메뉴 목록 조회
|
||||
* (현재 테넌트에 존재하지 않는 글로벌 메뉴만)
|
||||
* 글로벌 부모 메뉴 목록 조회 (드롭다운용)
|
||||
*/
|
||||
public function getAvailableGlobalMenus(int $tenantId): Collection
|
||||
public function getGlobalParentMenus(): Collection
|
||||
{
|
||||
return GlobalMenu::query()
|
||||
->where('is_active', true)
|
||||
->orderBy('sort_order')
|
||||
->orderBy('name')
|
||||
->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* 글로벌 메뉴 상세 조회
|
||||
*/
|
||||
public function getGlobalMenuById(int $id): ?GlobalMenu
|
||||
{
|
||||
return GlobalMenu::with(['parent', 'children'])->find($id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 글로벌 메뉴 목록 조회 (페이지네이션) - 트리 구조로 정렬
|
||||
*/
|
||||
public function getGlobalMenus(array $filters = [], int $perPage = 15): LengthAwarePaginator
|
||||
{
|
||||
$query = GlobalMenu::query()->withTrashed();
|
||||
|
||||
// Soft Delete 필터
|
||||
if (isset($filters['trashed'])) {
|
||||
if ($filters['trashed'] === 'only') {
|
||||
$query->onlyTrashed();
|
||||
} elseif ($filters['trashed'] === 'with') {
|
||||
$query->withTrashed();
|
||||
}
|
||||
}
|
||||
|
||||
// 검색 필터
|
||||
if (! empty($filters['search'])) {
|
||||
$search = $filters['search'];
|
||||
$query->where(function ($q) use ($search) {
|
||||
$q->where('name', 'like', "%{$search}%")
|
||||
->orWhere('url', 'like', "%{$search}%");
|
||||
});
|
||||
}
|
||||
|
||||
// 활성 상태 필터
|
||||
if (isset($filters['is_active'])) {
|
||||
$query->where('is_active', $filters['is_active']);
|
||||
}
|
||||
|
||||
// 모든 메뉴 가져오기 (트리 구조 정렬을 위해)
|
||||
$allMenus = $query->with(['parent'])->orderBy('sort_order')->orderBy('id')->get();
|
||||
|
||||
// 트리 구조로 정렬 후 플랫한 배열로 변환
|
||||
$flattenedMenus = $this->flattenMenuTree($allMenus);
|
||||
|
||||
// 수동 페이지네이션
|
||||
$currentPage = request()->input('page', 1);
|
||||
$offset = ($currentPage - 1) * $perPage;
|
||||
$items = $flattenedMenus->slice($offset, $perPage)->values();
|
||||
|
||||
return new \Illuminate\Pagination\LengthAwarePaginator(
|
||||
$items,
|
||||
$flattenedMenus->count(),
|
||||
$perPage,
|
||||
$currentPage,
|
||||
['path' => request()->url(), 'query' => request()->query()]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 글로벌 메뉴 생성
|
||||
*/
|
||||
public function createGlobalMenu(array $data): GlobalMenu
|
||||
{
|
||||
// is_active 처리
|
||||
$data['is_active'] = isset($data['is_active']) && $data['is_active'] == '1';
|
||||
|
||||
// hidden 처리
|
||||
$data['hidden'] = isset($data['hidden']) && $data['hidden'] == '1';
|
||||
|
||||
// is_external 처리
|
||||
$data['is_external'] = isset($data['is_external']) && $data['is_external'] == '1';
|
||||
|
||||
// parent_id null 처리
|
||||
if (empty($data['parent_id'])) {
|
||||
$data['parent_id'] = null;
|
||||
}
|
||||
|
||||
return GlobalMenu::create($data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 글로벌 메뉴 수정
|
||||
*/
|
||||
public function updateGlobalMenu(int $id, array $data): bool
|
||||
{
|
||||
$menu = $this->getGlobalMenuById($id);
|
||||
if (! $menu) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// is_active 처리
|
||||
$data['is_active'] = isset($data['is_active']) && $data['is_active'] == '1';
|
||||
|
||||
// hidden 처리
|
||||
$data['hidden'] = isset($data['hidden']) && $data['hidden'] == '1';
|
||||
|
||||
// is_external 처리
|
||||
$data['is_external'] = isset($data['is_external']) && $data['is_external'] == '1';
|
||||
|
||||
// parent_id null 처리
|
||||
if (empty($data['parent_id'])) {
|
||||
$data['parent_id'] = null;
|
||||
}
|
||||
|
||||
// 자기 자신을 부모로 설정하는 것 방지
|
||||
if (isset($data['parent_id']) && $data['parent_id'] == $id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $menu->update($data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 글로벌 메뉴 삭제 (Soft Delete)
|
||||
*/
|
||||
public function deleteGlobalMenu(int $id): bool
|
||||
{
|
||||
$menu = $this->getGlobalMenuById($id);
|
||||
if (! $menu) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 자식 메뉴가 있는 경우 삭제 불가
|
||||
if ($menu->children()->count() > 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $menu->delete();
|
||||
}
|
||||
|
||||
/**
|
||||
* 글로벌 메뉴 복원
|
||||
*/
|
||||
public function restoreGlobalMenu(int $id): bool
|
||||
{
|
||||
$menu = GlobalMenu::onlyTrashed()->findOrFail($id);
|
||||
|
||||
return $menu->restore();
|
||||
}
|
||||
|
||||
/**
|
||||
* 글로벌 메뉴 영구 삭제
|
||||
*/
|
||||
public function forceDeleteGlobalMenu(int $id): bool
|
||||
{
|
||||
$menu = GlobalMenu::withTrashed()->findOrFail($id);
|
||||
|
||||
// 자식 메뉴가 있는 경우 영구 삭제 불가
|
||||
if ($menu->children()->withTrashed()->count() > 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $menu->forceDelete();
|
||||
}
|
||||
|
||||
/**
|
||||
* 글로벌 메뉴 활성 상태 토글
|
||||
*/
|
||||
public function toggleGlobalActive(int $id): bool
|
||||
{
|
||||
$menu = $this->getGlobalMenuById($id);
|
||||
if (! $menu) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$menu->is_active = ! $menu->is_active;
|
||||
|
||||
return $menu->save();
|
||||
}
|
||||
|
||||
/**
|
||||
* 글로벌 메뉴 숨김 상태 토글
|
||||
*/
|
||||
public function toggleGlobalHidden(int $id): bool
|
||||
{
|
||||
$menu = $this->getGlobalMenuById($id);
|
||||
if (! $menu) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$menu->hidden = ! $menu->hidden;
|
||||
|
||||
return $menu->save();
|
||||
}
|
||||
|
||||
/**
|
||||
* 글로벌 메뉴 순서 변경
|
||||
*/
|
||||
public function reorderGlobalMenus(array $items): bool
|
||||
{
|
||||
return \DB::transaction(function () use ($items) {
|
||||
foreach ($items as $item) {
|
||||
GlobalMenu::where('id', $item['id'])
|
||||
->update(['sort_order' => $item['sort_order']]);
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 글로벌 메뉴 목록 조회 (가져오기 상태 포함)
|
||||
* - 모든 글로벌 메뉴 반환
|
||||
* - 이미 가져온 메뉴는 is_imported=true로 표시
|
||||
*/
|
||||
public function getAllGlobalMenusWithStatus(int $tenantId): Collection
|
||||
{
|
||||
// 글로벌 메뉴 전체 조회 (global_menus 테이블에서)
|
||||
$globalMenus = GlobalMenu::query()
|
||||
@@ -463,13 +676,13 @@ public function getAvailableGlobalMenus(int $tenantId): Collection
|
||||
->pluck('global_menu_id')
|
||||
->toArray();
|
||||
|
||||
// 현재 테넌트에 없는 글로벌 메뉴만 필터링
|
||||
$availableMenus = $globalMenus->filter(function ($menu) use ($existingGlobalIds) {
|
||||
return ! in_array($menu->id, $existingGlobalIds);
|
||||
// 각 메뉴에 is_imported 속성 추가
|
||||
$globalMenus->each(function ($menu) use ($existingGlobalIds) {
|
||||
$menu->is_imported = in_array($menu->id, $existingGlobalIds);
|
||||
});
|
||||
|
||||
// 트리 구조로 정렬 (depth 정보 포함)
|
||||
return $this->flattenMenuTree($availableMenus);
|
||||
return $this->flattenMenuTree($globalMenus);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
174
resources/views/menus/global-create.blade.php
Normal file
174
resources/views/menus/global-create.blade.php
Normal file
@@ -0,0 +1,174 @@
|
||||
@extends('layouts.app')
|
||||
|
||||
@section('title', '글로벌 메뉴 생성')
|
||||
|
||||
@section('content')
|
||||
<div class="max-w-4xl mx-auto">
|
||||
<!-- 페이지 헤더 -->
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-800">글로벌 메뉴 생성</h1>
|
||||
<p class="text-sm text-gray-500 mt-1">시스템 전체에서 사용되는 기본 메뉴를 생성합니다.</p>
|
||||
</div>
|
||||
<a href="{{ route('menus.global.index') }}" class="text-gray-600 hover:text-gray-800">
|
||||
← 목록으로
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- 폼 카드 -->
|
||||
<div class="bg-white rounded-lg shadow-sm p-6">
|
||||
<form id="menuForm" action="/api/admin/global-menus" method="POST">
|
||||
@csrf
|
||||
|
||||
<!-- 부모 메뉴 -->
|
||||
<div class="mb-4">
|
||||
<label for="parent_id" class="block text-sm font-medium text-gray-700 mb-2">
|
||||
부모 메뉴
|
||||
</label>
|
||||
<select name="parent_id" id="parent_id"
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500">
|
||||
<option value="">최상위 메뉴</option>
|
||||
@foreach($parentMenus as $parent)
|
||||
<option value="{{ $parent->id }}">{{ $parent->name }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- 메뉴명 -->
|
||||
<div class="mb-4">
|
||||
<label for="name" class="block text-sm font-medium text-gray-700 mb-2">
|
||||
메뉴명 <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input type="text" name="name" id="name" required
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500">
|
||||
</div>
|
||||
|
||||
<!-- URL -->
|
||||
<div class="mb-4">
|
||||
<label for="url" class="block text-sm font-medium text-gray-700 mb-2">
|
||||
URL
|
||||
</label>
|
||||
<input type="text" name="url" id="url"
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500"
|
||||
placeholder="/dashboard">
|
||||
</div>
|
||||
|
||||
<!-- 아이콘 -->
|
||||
<div class="mb-4">
|
||||
<label for="icon" class="block text-sm font-medium text-gray-700 mb-2">
|
||||
아이콘
|
||||
</label>
|
||||
<input type="text" name="icon" id="icon"
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500"
|
||||
placeholder="heroicon-o-home">
|
||||
</div>
|
||||
|
||||
<!-- 정렬 순서 -->
|
||||
<div class="mb-4">
|
||||
<label for="sort_order" class="block text-sm font-medium text-gray-700 mb-2">
|
||||
정렬 순서
|
||||
</label>
|
||||
<input type="number" name="sort_order" id="sort_order" value="0" min="0"
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500">
|
||||
</div>
|
||||
|
||||
<!-- 체크박스 그룹 -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4">
|
||||
<!-- 활성 상태 -->
|
||||
<div class="flex items-center">
|
||||
<input type="checkbox" name="is_active" id="is_active" value="1" checked
|
||||
class="w-4 h-4 text-purple-600 border-gray-300 rounded focus:ring-purple-500">
|
||||
<label for="is_active" class="ml-2 text-sm text-gray-700">
|
||||
활성 상태
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- 숨김 -->
|
||||
<div class="flex items-center">
|
||||
<input type="checkbox" name="hidden" id="hidden" value="1"
|
||||
class="w-4 h-4 text-purple-600 border-gray-300 rounded focus:ring-purple-500">
|
||||
<label for="hidden" class="ml-2 text-sm text-gray-700">
|
||||
숨김
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- 외부 링크 -->
|
||||
<div class="flex items-center">
|
||||
<input type="checkbox" name="is_external" id="is_external" value="1"
|
||||
class="w-4 h-4 text-purple-600 border-gray-300 rounded focus:ring-purple-500">
|
||||
<label for="is_external" class="ml-2 text-sm text-gray-700">
|
||||
외부 링크
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 외부 URL (is_external 체크 시 표시) -->
|
||||
<div id="external-url-group" class="mb-4 hidden">
|
||||
<label for="external_url" class="block text-sm font-medium text-gray-700 mb-2">
|
||||
외부 URL
|
||||
</label>
|
||||
<input type="text" name="external_url" id="external_url"
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500"
|
||||
placeholder="https://example.com">
|
||||
</div>
|
||||
|
||||
<!-- 버튼 그룹 -->
|
||||
<div class="flex justify-end gap-4 mt-6">
|
||||
<a href="{{ route('menus.global.index') }}"
|
||||
class="px-6 py-2 border border-gray-300 rounded-lg text-gray-700 hover:bg-gray-50 transition">
|
||||
취소
|
||||
</a>
|
||||
<button type="submit"
|
||||
class="px-6 py-2 bg-purple-600 hover:bg-purple-700 text-white rounded-lg transition">
|
||||
생성
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
|
||||
@push('scripts')
|
||||
<script>
|
||||
// 외부 링크 체크박스 토글
|
||||
document.getElementById('is_external').addEventListener('change', function() {
|
||||
const externalUrlGroup = document.getElementById('external-url-group');
|
||||
if (this.checked) {
|
||||
externalUrlGroup.classList.remove('hidden');
|
||||
} else {
|
||||
externalUrlGroup.classList.add('hidden');
|
||||
}
|
||||
});
|
||||
|
||||
// 폼 제출 처리
|
||||
document.getElementById('menuForm').addEventListener('submit', async function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const formData = new FormData(this);
|
||||
const data = Object.fromEntries(formData.entries());
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/admin/global-menus', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': '{{ csrf_token() }}'
|
||||
},
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
alert(result.message);
|
||||
window.location.href = result.redirect;
|
||||
} else {
|
||||
alert(result.message);
|
||||
}
|
||||
} catch (error) {
|
||||
alert('글로벌 메뉴 생성 중 오류가 발생했습니다.');
|
||||
console.error(error);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@endpush
|
||||
187
resources/views/menus/global-edit.blade.php
Normal file
187
resources/views/menus/global-edit.blade.php
Normal file
@@ -0,0 +1,187 @@
|
||||
@extends('layouts.app')
|
||||
|
||||
@section('title', '글로벌 메뉴 수정')
|
||||
|
||||
@section('content')
|
||||
<div class="max-w-4xl mx-auto">
|
||||
<!-- 페이지 헤더 -->
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-800">글로벌 메뉴 수정</h1>
|
||||
<p class="text-sm text-gray-500 mt-1">시스템 전체에서 사용되는 기본 메뉴를 수정합니다.</p>
|
||||
</div>
|
||||
<a href="{{ route('menus.global.index') }}" class="text-gray-600 hover:text-gray-800">
|
||||
← 목록으로
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- 폼 카드 -->
|
||||
<div class="bg-white rounded-lg shadow-sm p-6">
|
||||
<form id="menuForm" action="/api/admin/global-menus/{{ $menu->id }}" method="POST">
|
||||
@csrf
|
||||
@method('PUT')
|
||||
|
||||
<!-- 부모 메뉴 -->
|
||||
<div class="mb-4">
|
||||
<label for="parent_id" class="block text-sm font-medium text-gray-700 mb-2">
|
||||
부모 메뉴
|
||||
</label>
|
||||
<select name="parent_id" id="parent_id"
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500">
|
||||
<option value="">최상위 메뉴</option>
|
||||
@foreach($parentMenus as $parent)
|
||||
@if($parent->id != $menu->id)
|
||||
<option value="{{ $parent->id }}" {{ $menu->parent_id == $parent->id ? 'selected' : '' }}>
|
||||
{{ $parent->name }}
|
||||
</option>
|
||||
@endif
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- 메뉴명 -->
|
||||
<div class="mb-4">
|
||||
<label for="name" class="block text-sm font-medium text-gray-700 mb-2">
|
||||
메뉴명 <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input type="text" name="name" id="name" required
|
||||
value="{{ old('name', $menu->name) }}"
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500">
|
||||
</div>
|
||||
|
||||
<!-- URL -->
|
||||
<div class="mb-4">
|
||||
<label for="url" class="block text-sm font-medium text-gray-700 mb-2">
|
||||
URL
|
||||
</label>
|
||||
<input type="text" name="url" id="url"
|
||||
value="{{ old('url', $menu->url) }}"
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500"
|
||||
placeholder="/dashboard">
|
||||
</div>
|
||||
|
||||
<!-- 아이콘 -->
|
||||
<div class="mb-4">
|
||||
<label for="icon" class="block text-sm font-medium text-gray-700 mb-2">
|
||||
아이콘
|
||||
</label>
|
||||
<input type="text" name="icon" id="icon"
|
||||
value="{{ old('icon', $menu->icon) }}"
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500"
|
||||
placeholder="heroicon-o-home">
|
||||
</div>
|
||||
|
||||
<!-- 정렬 순서 -->
|
||||
<div class="mb-4">
|
||||
<label for="sort_order" class="block text-sm font-medium text-gray-700 mb-2">
|
||||
정렬 순서
|
||||
</label>
|
||||
<input type="number" name="sort_order" id="sort_order"
|
||||
value="{{ old('sort_order', $menu->sort_order ?? 0) }}" min="0"
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500">
|
||||
</div>
|
||||
|
||||
<!-- 체크박스 그룹 -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4">
|
||||
<!-- 활성 상태 -->
|
||||
<div class="flex items-center">
|
||||
<input type="checkbox" name="is_active" id="is_active" value="1"
|
||||
{{ old('is_active', $menu->is_active) ? 'checked' : '' }}
|
||||
class="w-4 h-4 text-purple-600 border-gray-300 rounded focus:ring-purple-500">
|
||||
<label for="is_active" class="ml-2 text-sm text-gray-700">
|
||||
활성 상태
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- 숨김 -->
|
||||
<div class="flex items-center">
|
||||
<input type="checkbox" name="hidden" id="hidden" value="1"
|
||||
{{ old('hidden', $menu->hidden) ? 'checked' : '' }}
|
||||
class="w-4 h-4 text-purple-600 border-gray-300 rounded focus:ring-purple-500">
|
||||
<label for="hidden" class="ml-2 text-sm text-gray-700">
|
||||
숨김
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- 외부 링크 -->
|
||||
<div class="flex items-center">
|
||||
<input type="checkbox" name="is_external" id="is_external" value="1"
|
||||
{{ old('is_external', $menu->is_external) ? 'checked' : '' }}
|
||||
class="w-4 h-4 text-purple-600 border-gray-300 rounded focus:ring-purple-500">
|
||||
<label for="is_external" class="ml-2 text-sm text-gray-700">
|
||||
외부 링크
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 외부 URL (is_external 체크 시 표시) -->
|
||||
<div id="external-url-group" class="mb-4 {{ $menu->is_external ? '' : 'hidden' }}">
|
||||
<label for="external_url" class="block text-sm font-medium text-gray-700 mb-2">
|
||||
외부 URL
|
||||
</label>
|
||||
<input type="text" name="external_url" id="external_url"
|
||||
value="{{ old('external_url', $menu->external_url) }}"
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500"
|
||||
placeholder="https://example.com">
|
||||
</div>
|
||||
|
||||
<!-- 버튼 그룹 -->
|
||||
<div class="flex justify-end gap-4 mt-6">
|
||||
<a href="{{ route('menus.global.index') }}"
|
||||
class="px-6 py-2 border border-gray-300 rounded-lg text-gray-700 hover:bg-gray-50 transition">
|
||||
취소
|
||||
</a>
|
||||
<button type="submit"
|
||||
class="px-6 py-2 bg-purple-600 hover:bg-purple-700 text-white rounded-lg transition">
|
||||
수정
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
|
||||
@push('scripts')
|
||||
<script>
|
||||
// 외부 링크 체크박스 토글
|
||||
document.getElementById('is_external').addEventListener('change', function() {
|
||||
const externalUrlGroup = document.getElementById('external-url-group');
|
||||
if (this.checked) {
|
||||
externalUrlGroup.classList.remove('hidden');
|
||||
} else {
|
||||
externalUrlGroup.classList.add('hidden');
|
||||
}
|
||||
});
|
||||
|
||||
// 폼 제출 처리
|
||||
document.getElementById('menuForm').addEventListener('submit', async function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const formData = new FormData(this);
|
||||
const data = Object.fromEntries(formData.entries());
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/admin/global-menus/{{ $menu->id }}', {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': '{{ csrf_token() }}'
|
||||
},
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
alert(result.message);
|
||||
window.location.href = result.redirect;
|
||||
} else {
|
||||
alert(result.message);
|
||||
}
|
||||
} catch (error) {
|
||||
alert('글로벌 메뉴 수정 중 오류가 발생했습니다.');
|
||||
console.error(error);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@endpush
|
||||
252
resources/views/menus/global-index.blade.php
Normal file
252
resources/views/menus/global-index.blade.php
Normal file
@@ -0,0 +1,252 @@
|
||||
@extends('layouts.app')
|
||||
|
||||
@section('title', '글로벌 메뉴 관리')
|
||||
|
||||
@section('content')
|
||||
<!-- 페이지 헤더 -->
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-800">글로벌 메뉴 관리</h1>
|
||||
<p class="text-sm text-gray-500 mt-1">
|
||||
시스템 전체에서 사용되는 기본 메뉴를 관리합니다. 테넌트는 이 메뉴를 복사하여 사용합니다.
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<a href="{{ route('menus.index') }}" class="bg-gray-500 hover:bg-gray-600 text-white px-4 py-2 rounded-lg transition flex items-center gap-2">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
||||
</svg>
|
||||
메뉴 관리로 돌아가기
|
||||
</a>
|
||||
<a href="{{ route('menus.global.create') }}" class="bg-purple-600 hover:bg-purple-700 text-white px-4 py-2 rounded-lg transition">
|
||||
+ 새 글로벌 메뉴
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 필터 영역 -->
|
||||
<div class="bg-white rounded-lg shadow-sm p-4 mb-6">
|
||||
<form id="filterForm" class="flex gap-4">
|
||||
<!-- 검색 -->
|
||||
<div class="flex-1">
|
||||
<input type="text"
|
||||
name="search"
|
||||
placeholder="메뉴명, URL로 검색..."
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500">
|
||||
</div>
|
||||
|
||||
<!-- 활성 상태 필터 -->
|
||||
<div class="w-48">
|
||||
<select name="is_active" class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500">
|
||||
<option value="">전체 상태</option>
|
||||
<option value="1">활성</option>
|
||||
<option value="0">비활성</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- 검색 버튼 -->
|
||||
<button type="submit" class="bg-purple-600 hover:bg-purple-700 text-white px-6 py-2 rounded-lg transition">
|
||||
검색
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- 테이블 영역 (HTMX로 로드) -->
|
||||
<div id="menu-table"
|
||||
hx-get="/api/admin/global-menus"
|
||||
hx-trigger="load, filterSubmit from:body"
|
||||
hx-include="#filterForm"
|
||||
hx-headers='{"X-CSRF-TOKEN": "{{ csrf_token() }}"}'
|
||||
class="bg-white rounded-lg shadow-sm overflow-hidden">
|
||||
<!-- 로딩 스피너 -->
|
||||
<div class="flex justify-center items-center p-12">
|
||||
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-purple-600"></div>
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
|
||||
@push('styles')
|
||||
<style>
|
||||
/* 드래그 인디케이터 스타일 */
|
||||
.drag-indicator {
|
||||
position: fixed;
|
||||
pointer-events: none;
|
||||
z-index: 9999;
|
||||
padding: 6px 12px;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
|
||||
}
|
||||
.drag-indicator.reorder {
|
||||
background: linear-gradient(135deg, #9333ea, #7c3aed);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.sortable-fallback {
|
||||
opacity: 0.9;
|
||||
background: white !important;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
|
||||
border-radius: 4px;
|
||||
z-index: 9998 !important;
|
||||
}
|
||||
|
||||
.drag-handle {
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
</style>
|
||||
@endpush
|
||||
|
||||
@push('scripts')
|
||||
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/sortablejs@1.15.2/Sortable.min.js"></script>
|
||||
<script>
|
||||
// 폼 제출 시 HTMX 이벤트 트리거
|
||||
document.getElementById('filterForm').addEventListener('submit', function(e) {
|
||||
e.preventDefault();
|
||||
htmx.trigger('#menu-table', 'filterSubmit');
|
||||
});
|
||||
|
||||
// HTMX 응답 처리 + SortableJS 초기화
|
||||
document.body.addEventListener('htmx:afterSwap', function(event) {
|
||||
if (event.detail.target.id === 'menu-table') {
|
||||
const response = JSON.parse(event.detail.xhr.response);
|
||||
if (response.html) {
|
||||
event.detail.target.innerHTML = response.html;
|
||||
initGlobalMenuSortable();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// SortableJS 초기화 함수
|
||||
function initGlobalMenuSortable() {
|
||||
const tbody = document.getElementById('menu-sortable');
|
||||
if (!tbody) return;
|
||||
|
||||
if (tbody.sortableInstance) {
|
||||
tbody.sortableInstance.destroy();
|
||||
}
|
||||
|
||||
tbody.sortableInstance = new Sortable(tbody, {
|
||||
handle: '.drag-handle',
|
||||
animation: 150,
|
||||
forceFallback: true,
|
||||
fallbackClass: 'sortable-fallback',
|
||||
fallbackOnBody: true,
|
||||
ghostClass: 'bg-purple-50',
|
||||
chosenClass: 'bg-purple-100',
|
||||
|
||||
onEnd: function(evt) {
|
||||
const rows = Array.from(tbody.querySelectorAll('tr.menu-row'));
|
||||
const items = rows.map((row, index) => ({
|
||||
id: parseInt(row.dataset.menuId),
|
||||
sort_order: index + 1
|
||||
}));
|
||||
|
||||
saveGlobalMenuOrder(items);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 메뉴 순서 저장 API 호출
|
||||
function saveGlobalMenuOrder(items) {
|
||||
fetch('/api/admin/global-menus/reorder', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': '{{ csrf_token() }}',
|
||||
'Accept': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ items: items })
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
htmx.trigger('#menu-table', 'filterSubmit');
|
||||
} else {
|
||||
alert('순서 변경 실패: ' + (data.message || ''));
|
||||
htmx.trigger('#menu-table', 'filterSubmit');
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error:', error);
|
||||
alert('순서 변경 중 오류 발생');
|
||||
htmx.trigger('#menu-table', 'filterSubmit');
|
||||
});
|
||||
}
|
||||
|
||||
// 삭제 확인
|
||||
window.confirmDelete = function(id, name) {
|
||||
if (confirm(`"${name}" 글로벌 메뉴를 삭제하시겠습니까?`)) {
|
||||
htmx.ajax('DELETE', `/api/admin/global-menus/${id}`, {
|
||||
target: '#menu-table',
|
||||
swap: 'none',
|
||||
headers: {
|
||||
'X-CSRF-TOKEN': '{{ csrf_token() }}'
|
||||
}
|
||||
}).then(() => {
|
||||
htmx.trigger('#menu-table', 'filterSubmit');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 복원 확인
|
||||
window.confirmRestore = function(id, name) {
|
||||
if (confirm(`"${name}" 글로벌 메뉴를 복원하시겠습니까?`)) {
|
||||
htmx.ajax('POST', `/api/admin/global-menus/${id}/restore`, {
|
||||
target: '#menu-table',
|
||||
swap: 'none',
|
||||
headers: {
|
||||
'X-CSRF-TOKEN': '{{ csrf_token() }}'
|
||||
}
|
||||
}).then(() => {
|
||||
htmx.trigger('#menu-table', 'filterSubmit');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 영구삭제 확인
|
||||
window.confirmForceDelete = function(id, name) {
|
||||
if (confirm(`⚠️ 경고: "${name}" 글로벌 메뉴를 영구 삭제하시겠습니까?\n\n이 작업은 되돌릴 수 없습니다!`)) {
|
||||
htmx.ajax('DELETE', `/api/admin/global-menus/${id}/force`, {
|
||||
target: '#menu-table',
|
||||
swap: 'none',
|
||||
headers: {
|
||||
'X-CSRF-TOKEN': '{{ csrf_token() }}'
|
||||
}
|
||||
}).then(() => {
|
||||
htmx.trigger('#menu-table', 'filterSubmit');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 활성 토글
|
||||
window.toggleActive = function(id) {
|
||||
htmx.ajax('POST', `/api/admin/global-menus/${id}/toggle-active`, {
|
||||
target: '#menu-table',
|
||||
swap: 'none',
|
||||
headers: {
|
||||
'X-CSRF-TOKEN': '{{ csrf_token() }}'
|
||||
}
|
||||
}).then(() => {
|
||||
htmx.trigger('#menu-table', 'filterSubmit');
|
||||
});
|
||||
};
|
||||
|
||||
// 숨김 토글
|
||||
window.toggleHidden = function(id) {
|
||||
htmx.ajax('POST', `/api/admin/global-menus/${id}/toggle-hidden`, {
|
||||
target: '#menu-table',
|
||||
swap: 'none',
|
||||
headers: {
|
||||
'X-CSRF-TOKEN': '{{ csrf_token() }}'
|
||||
}
|
||||
}).then(() => {
|
||||
htmx.trigger('#menu-table', 'filterSubmit');
|
||||
});
|
||||
};
|
||||
</script>
|
||||
<script src="{{ asset('js/menu-tree.js') }}"></script>
|
||||
@endpush
|
||||
@@ -40,6 +40,14 @@ class="bg-green-600 hover:bg-green-700 text-white px-4 py-2 rounded-lg transitio
|
||||
<a href="{{ route('menus.create') }}" id="newMenuBtn" class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg transition">
|
||||
+ 새 메뉴
|
||||
</a>
|
||||
@if(auth()->user()?->is_super_admin)
|
||||
<a href="{{ route('menus.global.index') }}" class="bg-purple-600 hover:bg-purple-700 text-white px-4 py-2 rounded-lg transition flex items-center gap-2">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3.055 11H5a2 2 0 012 2v1a2 2 0 002 2 2 2 0 012 2v2.945M8 3.935V5.5A2.5 2.5 0 0010.5 8h.5a2 2 0 012 2 2 2 0 104 0 2 2 0 012-2h1.064M15 20.488V18a2 2 0 012-2h3.064M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
글로벌 메뉴 관리
|
||||
</a>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -57,8 +65,8 @@ class="bg-green-600 hover:bg-green-700 text-white px-4 py-2 rounded-lg transitio
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
</div>
|
||||
|
||||
<!-- 활성 상태 필터 -->
|
||||
<div class="w-48">
|
||||
<!-- 활성 상태 필터 (일반 모드) -->
|
||||
<div class="w-48" id="activeFilter">
|
||||
<select name="is_active" class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
<option value="">전체 상태</option>
|
||||
<option value="1">활성</option>
|
||||
@@ -66,6 +74,14 @@ class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none foc
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- 가져오기 상태 필터 (가져오기 모드) -->
|
||||
<div class="w-48 hidden" id="importFilter">
|
||||
<select name="import_status" id="importStatusSelect" class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-green-500" onchange="filterImportedMenus()">
|
||||
<option value="all">전체 메뉴</option>
|
||||
<option value="available">가져올 수 있는 메뉴</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- 검색 버튼 -->
|
||||
<button type="submit" class="bg-gray-600 hover:bg-gray-700 text-white px-6 py-2 rounded-lg transition">
|
||||
검색
|
||||
@@ -628,6 +644,8 @@ function saveMenuOrder(items) {
|
||||
const newMenuBtn = document.getElementById('newMenuBtn');
|
||||
const modeDescription = document.getElementById('modeDescription');
|
||||
const modeInput = document.getElementById('modeInput');
|
||||
const activeFilter = document.getElementById('activeFilter');
|
||||
const importFilter = document.getElementById('importFilter');
|
||||
|
||||
// hidden input 값 업데이트
|
||||
modeInput.value = mode === 'import' ? 'import' : '';
|
||||
@@ -646,6 +664,10 @@ function saveMenuOrder(items) {
|
||||
importActionBtn.classList.add('flex');
|
||||
newMenuBtn.classList.add('hidden');
|
||||
|
||||
// 필터 전환: 활성 상태 → 가져오기 상태
|
||||
activeFilter.classList.add('hidden');
|
||||
importFilter.classList.remove('hidden');
|
||||
|
||||
// 설명 변경
|
||||
modeDescription.innerHTML = '글로벌 메뉴에서 가져올 항목을 선택하세요. 체크박스로 선택 후 <span class="font-medium text-green-600">선택 가져오기</span> 버튼을 클릭하세요.';
|
||||
} else {
|
||||
@@ -661,6 +683,10 @@ function saveMenuOrder(items) {
|
||||
importActionBtn.classList.remove('flex');
|
||||
newMenuBtn.classList.remove('hidden');
|
||||
|
||||
// 필터 전환: 가져오기 상태 → 활성 상태
|
||||
activeFilter.classList.remove('hidden');
|
||||
importFilter.classList.add('hidden');
|
||||
|
||||
// 설명 복원
|
||||
modeDescription.innerHTML = '드래그: 순서 변경 | <span class="font-medium text-blue-600">→ 오른쪽</span>: 하위로 이동 | <span class="font-medium text-orange-600">← 왼쪽</span>: 상위로 이동';
|
||||
}
|
||||
@@ -669,28 +695,58 @@ function saveMenuOrder(items) {
|
||||
htmx.trigger('#menu-table', 'filterSubmit');
|
||||
};
|
||||
|
||||
// 가져오기 버튼 상태 업데이트
|
||||
// 가져오기 버튼 상태 업데이트 (활성화된 체크박스만 카운트)
|
||||
window.updateImportButtonState = function() {
|
||||
const checkboxes = document.querySelectorAll('#menu-sortable .import-checkbox:checked');
|
||||
// disabled가 아닌 체크박스만 선택
|
||||
const checkedBoxes = document.querySelectorAll('#menu-sortable .import-checkbox:checked:not(:disabled)');
|
||||
const importBtn = document.getElementById('importBtn');
|
||||
const selectedCount = document.getElementById('selectedCount');
|
||||
|
||||
if (importBtn && selectedCount) {
|
||||
selectedCount.textContent = checkboxes.length;
|
||||
importBtn.disabled = checkboxes.length === 0;
|
||||
selectedCount.textContent = checkedBoxes.length;
|
||||
importBtn.disabled = checkedBoxes.length === 0;
|
||||
}
|
||||
};
|
||||
|
||||
// 전체 선택/해제 (가져오기용)
|
||||
window.toggleSelectAllImport = function(checkbox) {
|
||||
const checkboxes = document.querySelectorAll('#menu-sortable .import-checkbox');
|
||||
checkboxes.forEach(cb => cb.checked = checkbox.checked);
|
||||
// 전체 선택/해제 (가져오기용 - 가져올 수 있는 것만)
|
||||
window.toggleSelectAllImport = function(headerCheckbox) {
|
||||
// disabled가 아닌 체크박스만 선택 대상
|
||||
const enabledCheckboxes = document.querySelectorAll('#menu-sortable .import-checkbox:not(:disabled)');
|
||||
enabledCheckboxes.forEach(cb => cb.checked = headerCheckbox.checked);
|
||||
updateImportButtonState();
|
||||
};
|
||||
|
||||
// 선택된 글로벌 메뉴 가져오기
|
||||
// 가져오기 상태 필터링 (클라이언트 사이드)
|
||||
window.filterImportedMenus = function() {
|
||||
const filterValue = document.getElementById('importStatusSelect').value;
|
||||
const rows = document.querySelectorAll('#menu-sortable .menu-row');
|
||||
|
||||
rows.forEach(row => {
|
||||
const checkbox = row.querySelector('.import-checkbox');
|
||||
if (!checkbox) return;
|
||||
|
||||
const isImported = checkbox.disabled; // disabled면 이미 가져온 것
|
||||
|
||||
if (filterValue === 'available') {
|
||||
// 가져올 수 있는 메뉴만 표시
|
||||
row.style.display = isImported ? 'none' : '';
|
||||
} else {
|
||||
// 전체 표시
|
||||
row.style.display = '';
|
||||
}
|
||||
});
|
||||
|
||||
// 전체 선택 체크박스 해제
|
||||
const selectAllCheckbox = document.getElementById('selectAllImport');
|
||||
if (selectAllCheckbox) {
|
||||
selectAllCheckbox.checked = false;
|
||||
}
|
||||
updateImportButtonState();
|
||||
};
|
||||
|
||||
// 선택된 글로벌 메뉴 가져오기 (가져올 수 있는 것만)
|
||||
window.importSelectedMenus = function() {
|
||||
const checkboxes = document.querySelectorAll('#menu-sortable .import-checkbox:checked');
|
||||
const checkboxes = document.querySelectorAll('#menu-sortable .import-checkbox:checked:not(:disabled)');
|
||||
const menuIds = Array.from(checkboxes).map(cb => parseInt(cb.value));
|
||||
|
||||
if (menuIds.length === 0) {
|
||||
|
||||
143
resources/views/menus/partials/global-table.blade.php
Normal file
143
resources/views/menus/partials/global-table.blade.php
Normal file
@@ -0,0 +1,143 @@
|
||||
<div class="bg-white rounded-lg shadow-sm overflow-hidden">
|
||||
<table class="w-full">
|
||||
<thead class="bg-purple-50 border-b">
|
||||
<tr>
|
||||
<th class="px-2 py-3 text-center text-sm font-semibold text-gray-700 uppercase tracking-wider w-10"></th>
|
||||
<th class="px-4 py-3 text-left text-sm font-semibold text-gray-700 uppercase tracking-wider w-16">No.</th>
|
||||
<th class="px-4 py-3 text-left text-sm font-semibold text-gray-700 uppercase tracking-wider">메뉴명</th>
|
||||
<th class="px-4 py-3 text-left text-sm font-semibold text-gray-700 uppercase tracking-wider">URL</th>
|
||||
<th class="px-4 py-3 text-left text-sm font-semibold text-gray-700 uppercase tracking-wider w-16">정렬</th>
|
||||
<th class="px-4 py-3 text-center text-sm font-semibold text-gray-700 uppercase tracking-wider w-16">활성</th>
|
||||
<th class="px-4 py-3 text-center text-sm font-semibold text-gray-700 uppercase tracking-wider w-16">숨김</th>
|
||||
<th class="px-4 py-3 text-left text-sm font-semibold text-gray-700 uppercase tracking-wider">작업</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="menu-sortable" class="bg-white divide-y divide-gray-200">
|
||||
@forelse($menus as $menu)
|
||||
<tr class="menu-row {{ $menu->deleted_at ? 'bg-red-50' : 'hover:bg-gray-50' }}"
|
||||
data-menu-id="{{ $menu->id }}"
|
||||
data-parent-id="{{ $menu->parent_id ?? '' }}"
|
||||
data-sort-order="{{ $menu->sort_order ?? 0 }}"
|
||||
data-depth="{{ $menu->depth ?? 0 }}">
|
||||
<td class="px-2 py-4 whitespace-nowrap text-center">
|
||||
@if(!$menu->deleted_at)
|
||||
<span class="drag-handle cursor-grab active:cursor-grabbing text-gray-400 hover:text-gray-600">
|
||||
<svg class="w-5 h-5 inline-block" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 8h16M4 16h16" />
|
||||
</svg>
|
||||
</span>
|
||||
@endif
|
||||
</td>
|
||||
<td class="px-4 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
{{ $loop->iteration }}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<div class="flex items-center gap-2" style="padding-left: {{ (($menu->depth ?? 0) * 1.5) }}rem;">
|
||||
@if(($menu->depth ?? 0) > 0)
|
||||
<span class="text-gray-300 text-xs font-mono flex-shrink-0">└─</span>
|
||||
@endif
|
||||
|
||||
@if($menu->has_children)
|
||||
<button type="button"
|
||||
onclick="toggleChildren({{ $menu->id }})"
|
||||
class="toggle-btn flex items-center text-purple-500 hover:text-purple-700 focus:outline-none"
|
||||
data-menu-id="{{ $menu->id }}">
|
||||
<svg class="w-4 h-4 transform transition-transform flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
|
||||
</svg>
|
||||
<svg class="w-3 h-3 ml-0.5 transform transition-transform chevron-icon" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
</button>
|
||||
@else
|
||||
<svg class="w-4 h-4 text-gray-400 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
@endif
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm {{ ($menu->depth ?? 0) === 0 ? 'font-semibold text-gray-900' : 'font-medium text-gray-700' }}">
|
||||
{{ $menu->name }}
|
||||
</span>
|
||||
@if($menu->is_external)
|
||||
<span class="text-xs text-purple-600">(외부)</span>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
@if($menu->is_external && $menu->external_url)
|
||||
<a href="{{ $menu->external_url }}" target="_blank" class="text-purple-600 hover:underline">
|
||||
{{ Str::limit($menu->external_url, 30) }}
|
||||
</a>
|
||||
@elseif($menu->url)
|
||||
{{ $menu->url }}
|
||||
@else
|
||||
-
|
||||
@endif
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{{ $menu->sort_order ?? 0 }}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-center">
|
||||
@if(!$menu->deleted_at)
|
||||
<button type="button"
|
||||
onclick="toggleActive({{ $menu->id }})"
|
||||
class="relative inline-flex h-4 w-8 items-center rounded-full transition-colors focus:outline-none focus:ring-1 focus:ring-purple-500 focus:ring-offset-1 {{ $menu->is_active ? 'bg-purple-500' : 'bg-gray-400' }}">
|
||||
<span class="inline-block h-3 w-3 transform rounded-full bg-white shadow-sm transition-transform {{ $menu->is_active ? 'translate-x-4' : 'translate-x-0.5' }}"></span>
|
||||
</button>
|
||||
@else
|
||||
<span class="text-gray-400">-</span>
|
||||
@endif
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-center">
|
||||
@if(!$menu->deleted_at)
|
||||
<button type="button"
|
||||
onclick="toggleHidden({{ $menu->id }})"
|
||||
class="relative inline-flex h-4 w-8 items-center rounded-full transition-colors focus:outline-none focus:ring-1 focus:ring-amber-500 focus:ring-offset-1 {{ $menu->hidden ? 'bg-amber-500' : 'bg-gray-400' }}">
|
||||
<span class="inline-block h-3 w-3 transform rounded-full bg-white shadow-sm transition-transform {{ $menu->hidden ? 'translate-x-4' : 'translate-x-0.5' }}"></span>
|
||||
</button>
|
||||
@else
|
||||
<span class="text-gray-400">-</span>
|
||||
@endif
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
||||
@if($menu->deleted_at)
|
||||
<button onclick="confirmRestore({{ $menu->id }}, '{{ $menu->name }}')"
|
||||
class="text-green-600 hover:text-green-900 mr-3">
|
||||
복원
|
||||
</button>
|
||||
<button onclick="confirmForceDelete({{ $menu->id }}, '{{ $menu->name }}')"
|
||||
class="text-red-600 hover:text-red-900">
|
||||
영구삭제
|
||||
</button>
|
||||
@else
|
||||
<div class="flex items-center gap-2">
|
||||
<a href="{{ route('menus.global.edit', $menu->id) }}" class="text-purple-600 hover:text-purple-900">
|
||||
수정
|
||||
</a>
|
||||
<button onclick="confirmDelete({{ $menu->id }}, '{{ $menu->name }}')" class="text-red-600 hover:text-red-900">
|
||||
삭제
|
||||
</button>
|
||||
</div>
|
||||
@endif
|
||||
</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr>
|
||||
<td colspan="8" class="px-6 py-4 text-center text-gray-500">
|
||||
글로벌 메뉴가 없습니다.
|
||||
</td>
|
||||
</tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
@if(method_exists($menus, 'hasPages'))
|
||||
@include('partials.pagination', [
|
||||
'paginator' => $menus,
|
||||
'target' => '#menu-table',
|
||||
'includeForm' => '#filterForm'
|
||||
])
|
||||
@endif
|
||||
@@ -24,7 +24,7 @@ class="w-4 h-4 rounded border-gray-300 text-green-600 focus:ring-green-500">
|
||||
</thead>
|
||||
<tbody id="menu-sortable" class="bg-white divide-y divide-gray-200">
|
||||
@forelse($menus as $menu)
|
||||
<tr class="hover:bg-gray-50 menu-row {{ $menu->deleted_at ? 'bg-red-50' : '' }}"
|
||||
<tr class="menu-row {{ $menu->deleted_at ? 'bg-red-50' : '' }} {{ ($importMode ?? false) && ($menu->is_imported ?? false) ? 'bg-gray-50 opacity-50' : 'hover:bg-gray-50' }}"
|
||||
data-menu-id="{{ $menu->id }}"
|
||||
data-parent-id="{{ $menu->parent_id ?? '' }}"
|
||||
data-sort-order="{{ $menu->sort_order ?? 0 }}"
|
||||
@@ -32,10 +32,19 @@ class="w-4 h-4 rounded border-gray-300 text-green-600 focus:ring-green-500">
|
||||
{{-- 체크박스 또는 드래그 핸들 --}}
|
||||
@if($importMode ?? false)
|
||||
<td class="px-2 py-4 whitespace-nowrap text-center">
|
||||
<input type="checkbox"
|
||||
value="{{ $menu->id }}"
|
||||
onchange="updateImportButtonState()"
|
||||
class="import-checkbox w-4 h-4 rounded border-gray-300 text-green-600 focus:ring-green-500">
|
||||
@if($menu->is_imported ?? false)
|
||||
{{-- 이미 가져온 메뉴: 비활성화 --}}
|
||||
<input type="checkbox"
|
||||
value="{{ $menu->id }}"
|
||||
disabled
|
||||
class="import-checkbox w-4 h-4 rounded border-gray-300 text-gray-400 cursor-not-allowed">
|
||||
@else
|
||||
{{-- 가져올 수 있는 메뉴 --}}
|
||||
<input type="checkbox"
|
||||
value="{{ $menu->id }}"
|
||||
onchange="updateImportButtonState()"
|
||||
class="import-checkbox w-4 h-4 rounded border-gray-300 text-green-600 focus:ring-green-500">
|
||||
@endif
|
||||
</td>
|
||||
@else
|
||||
<td class="px-2 py-4 whitespace-nowrap text-center">
|
||||
@@ -140,10 +149,16 @@ class="relative inline-flex h-4 w-8 items-center rounded-full transition-colors
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
||||
@if($importMode ?? false)
|
||||
<!-- 가져오기 모드: 글로벌 메뉴임을 표시 -->
|
||||
<span class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-purple-100 text-purple-700">
|
||||
글로벌
|
||||
</span>
|
||||
<!-- 가져오기 모드: 가져온 메뉴 vs 가져올 수 있는 메뉴 구분 -->
|
||||
@if($menu->is_imported ?? false)
|
||||
<span class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-gray-200 text-gray-500">
|
||||
가져옴
|
||||
</span>
|
||||
@else
|
||||
<span class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-green-100 text-green-700">
|
||||
가져오기 가능
|
||||
</span>
|
||||
@endif
|
||||
@elseif($menu->deleted_at)
|
||||
<!-- 삭제된 항목 - 복원은 일반관리자도 가능, 영구삭제는 슈퍼관리자만 -->
|
||||
<button onclick="confirmRestore({{ $menu->id }}, '{{ $menu->name }}')"
|
||||
@@ -158,12 +173,19 @@ class="text-red-600 hover:text-red-900">
|
||||
@endif
|
||||
@else
|
||||
<!-- 활성 항목 -->
|
||||
<a href="{{ route('menus.edit', $menu->id) }}" class="text-blue-600 hover:text-blue-900 mr-3">
|
||||
수정
|
||||
</a>
|
||||
<button onclick="confirmDelete({{ $menu->id }}, '{{ $menu->name }}')" class="text-red-600 hover:text-red-900">
|
||||
삭제
|
||||
</button>
|
||||
<div class="flex items-center gap-2">
|
||||
@if($menu->global_menu_id)
|
||||
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-purple-100 text-purple-700">
|
||||
글로벌
|
||||
</span>
|
||||
@endif
|
||||
<a href="{{ route('menus.edit', $menu->id) }}" class="text-blue-600 hover:text-blue-900">
|
||||
수정
|
||||
</a>
|
||||
<button onclick="confirmDelete({{ $menu->id }}, '{{ $menu->name }}')" class="text-red-600 hover:text-red-900">
|
||||
삭제
|
||||
</button>
|
||||
</div>
|
||||
@endif
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
use App\Http\Controllers\Api\Admin\BoardController;
|
||||
use App\Http\Controllers\Api\Admin\DailyLogController;
|
||||
use App\Http\Controllers\Api\Admin\DepartmentController;
|
||||
use App\Http\Controllers\Api\Admin\GlobalMenuController;
|
||||
use App\Http\Controllers\Api\Admin\MenuController;
|
||||
use App\Http\Controllers\Api\Admin\PermissionController;
|
||||
use App\Http\Controllers\Api\Admin\ProjectManagement\ImportController as PmImportController;
|
||||
@@ -135,6 +136,29 @@
|
||||
Route::post('/{id}/toggle-hidden', [MenuController::class, 'toggleHidden'])->name('toggleHidden');
|
||||
});
|
||||
|
||||
// 글로벌 메뉴 관리 API (슈퍼관리자 전용)
|
||||
Route::middleware('super.admin')->prefix('global-menus')->name('global-menus.')->group(function () {
|
||||
// 고정 경로
|
||||
Route::post('/reorder', [GlobalMenuController::class, 'reorder'])->name('reorder');
|
||||
|
||||
// 기본 CRUD
|
||||
Route::get('/', [GlobalMenuController::class, 'index'])->name('index');
|
||||
Route::post('/', [GlobalMenuController::class, 'store'])->name('store');
|
||||
Route::get('/{id}', [GlobalMenuController::class, 'show'])->name('show');
|
||||
Route::put('/{id}', [GlobalMenuController::class, 'update'])->name('update');
|
||||
Route::delete('/{id}', [GlobalMenuController::class, 'destroy'])->name('destroy');
|
||||
|
||||
// 복원
|
||||
Route::post('/{id}/restore', [GlobalMenuController::class, 'restore'])->name('restore');
|
||||
|
||||
// 영구삭제
|
||||
Route::delete('/{id}/force', [GlobalMenuController::class, 'forceDestroy'])->name('forceDestroy');
|
||||
|
||||
// 추가 액션
|
||||
Route::post('/{id}/toggle-active', [GlobalMenuController::class, 'toggleActive'])->name('toggleActive');
|
||||
Route::post('/{id}/toggle-hidden', [GlobalMenuController::class, 'toggleHidden'])->name('toggleHidden');
|
||||
});
|
||||
|
||||
// 권한 관리 API
|
||||
Route::prefix('permissions')->name('permissions.')->group(function () {
|
||||
Route::get('/', [PermissionController::class, 'index'])->name('index');
|
||||
|
||||
@@ -82,6 +82,11 @@
|
||||
Route::get('/', [MenuController::class, 'index'])->name('index');
|
||||
Route::get('/create', [MenuController::class, 'create'])->name('create');
|
||||
Route::get('/{id}/edit', [MenuController::class, 'edit'])->name('edit');
|
||||
|
||||
// 글로벌 메뉴 관리 (슈퍼관리자 전용)
|
||||
Route::get('/global', [MenuController::class, 'globalIndex'])->name('global.index');
|
||||
Route::get('/global/create', [MenuController::class, 'globalCreate'])->name('global.create');
|
||||
Route::get('/global/{id}/edit', [MenuController::class, 'globalEdit'])->name('global.edit');
|
||||
});
|
||||
|
||||
// 권한 관리 (Blade 화면만)
|
||||
|
||||
Reference in New Issue
Block a user