메뉴 관리 기능 개선

- 트리 구조 정렬 및 표시 (무제한 depth 지원)
- 접기/펼치기 기능 추가 (재귀적 처리)
- 활성/숨김 상태 토글 기능 (실시간 업데이트)
- 테넌트 필터링 (전체 선택 시 마스터 메뉴만 표시)
- UI 개선 (토글 버튼, 외부 메뉴 표시)

추가된 파일:
- MenuService: 비즈니스 로직 처리
- MenuController (API/Web): 라우트 처리
- MenuRequest: 유효성 검증
- views/menus/: 메뉴 관리 뷰

수정된 파일:
- routes/api.php, web.php: 메뉴 라우트 추가
This commit is contained in:
2025-11-24 22:02:09 +09:00
parent 43af1a5779
commit 79aebfa148
11 changed files with 1514 additions and 0 deletions

View File

@@ -0,0 +1,295 @@
<?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 MenuController extends Controller
{
public function __construct(
private readonly MenuService $menuService
) {}
/**
* 메뉴 목록 조회
*/
public function index(Request $request): JsonResponse
{
$menus = $this->menuService->getMenus(
$request->all(),
$request->integer('per_page', 10)
);
// HTMX 요청인 경우 HTML 반환
if ($request->header('HX-Request')) {
$html = view('menus.partials.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 tree(Request $request): JsonResponse
{
$tenantId = $request->integer('tenant_id') ?: session('selected_tenant_id');
$tree = $this->menuService->getMenuTree($tenantId);
return response()->json([
'success' => true,
'data' => $tree,
]);
}
/**
* 메뉴 상세 조회
*/
public function show(int $id): JsonResponse
{
$menu = $this->menuService->getMenuById($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->createMenu($request->validated());
return response()->json([
'success' => true,
'message' => '메뉴가 생성되었습니다.',
'data' => $menu,
'redirect' => route('menus.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->updateMenu($id, $request->validated());
if (!$result) {
return response()->json([
'success' => false,
'message' => '메뉴를 찾을 수 없거나 수정할 수 없습니다.',
], 404);
}
return response()->json([
'success' => true,
'message' => '메뉴가 수정되었습니다.',
'redirect' => route('menus.index'),
]);
} catch (\Exception $e) {
return response()->json([
'success' => false,
'message' => '메뉴 수정에 실패했습니다: ' . $e->getMessage(),
], 500);
}
}
/**
* 메뉴 삭제
*/
public function destroy(int $id): JsonResponse
{
try {
$result = $this->menuService->deleteMenu($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->restoreMenu($id);
// HTMX 요청 시 테이블 새로고침 트리거
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
{
// 슈퍼관리자 권한 체크
if (!auth()->user()?->is_super_admin) {
return response()->json([
'success' => false,
'message' => '권한이 없습니다.',
], 403);
}
try {
$result = $this->menuService->forceDeleteMenu($id);
if (!$result) {
return response()->json([
'success' => false,
'message' => '메뉴를 찾을 수 없거나 자식 메뉴가 있어 영구 삭제할 수 없습니다.',
], 404);
}
// HTMX 요청 시 테이블 새로고침 트리거
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->toggleActive($id);
if (!$result) {
return response()->json([
'success' => false,
'message' => '메뉴를 찾을 수 없습니다.',
], 404);
}
// HTMX 요청 시 테이블 새로고침 트리거
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->toggleHidden($id);
if (!$result) {
return response()->json([
'success' => false,
'message' => '메뉴를 찾을 수 없습니다.',
], 404);
}
// HTMX 요청 시 테이블 새로고침 트리거
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);
}
}
}

View File

@@ -0,0 +1,48 @@
<?php
namespace App\Http\Controllers;
use App\Services\MenuService;
use Illuminate\Http\Request;
use Illuminate\View\View;
class MenuController extends Controller
{
public function __construct(
private readonly MenuService $menuService
) {}
/**
* 메뉴 목록 페이지
*/
public function index(Request $request): View
{
return view('menus.index');
}
/**
* 메뉴 생성 페이지
*/
public function create(): View
{
$parentMenus = $this->menuService->getParentMenus();
return view('menus.create', compact('parentMenus'));
}
/**
* 메뉴 수정 페이지
*/
public function edit(int $id): View
{
$menu = $this->menuService->getMenuById($id);
if (!$menu) {
abort(404, '메뉴를 찾을 수 없습니다.');
}
$parentMenus = $this->menuService->getParentMenus();
return view('menus.edit', compact('menu', 'parentMenus'));
}
}

View File

@@ -0,0 +1,69 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class StoreMenuRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'parent_id' => 'nullable|exists:menus,id',
'name' => 'required|string|max:100',
'url' => 'nullable|string|max:255',
'icon' => 'nullable|string|max:100',
'sort_order' => 'nullable|integer|min:0',
'is_active' => 'nullable|boolean',
'hidden' => 'nullable|boolean',
'is_external' => 'nullable|boolean',
'external_url' => 'nullable|string|max:255|required_if:is_external,1',
];
}
/**
* Get custom attributes for validator errors.
*
* @return array<string, string>
*/
public function attributes(): array
{
return [
'parent_id' => '부모 메뉴',
'name' => '메뉴명',
'url' => 'URL',
'icon' => '아이콘',
'sort_order' => '정렬 순서',
'is_active' => '활성 상태',
'hidden' => '숨김 여부',
'is_external' => '외부 링크',
'external_url' => '외부 URL',
];
}
/**
* Get custom messages for validator errors.
*
* @return array<string, string>
*/
public function messages(): array
{
return [
'parent_id.exists' => '존재하지 않는 부모 메뉴입니다.',
'external_url.required_if' => '외부 링크 사용 시 외부 URL은 필수입니다.',
];
}
}

View File

@@ -0,0 +1,80 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class UpdateMenuRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
$menuId = $this->route('id');
return [
'parent_id' => [
'nullable',
'exists:menus,id',
function ($attribute, $value, $fail) use ($menuId) {
// 자기 자신을 부모로 설정 방지
if ($value == $menuId) {
$fail('자기 자신을 부모 메뉴로 설정할 수 없습니다.');
}
},
],
'name' => 'required|string|max:100',
'url' => 'nullable|string|max:255',
'icon' => 'nullable|string|max:100',
'sort_order' => 'nullable|integer|min:0',
'is_active' => 'nullable|boolean',
'hidden' => 'nullable|boolean',
'is_external' => 'nullable|boolean',
'external_url' => 'nullable|string|max:255|required_if:is_external,1',
];
}
/**
* Get custom attributes for validator errors.
*
* @return array<string, string>
*/
public function attributes(): array
{
return [
'parent_id' => '부모 메뉴',
'name' => '메뉴명',
'url' => 'URL',
'icon' => '아이콘',
'sort_order' => '정렬 순서',
'is_active' => '활성 상태',
'hidden' => '숨김 여부',
'is_external' => '외부 링크',
'external_url' => '외부 URL',
];
}
/**
* Get custom messages for validator errors.
*
* @return array<string, string>
*/
public function messages(): array
{
return [
'parent_id.exists' => '존재하지 않는 부모 메뉴입니다.',
'external_url.required_if' => '외부 링크 사용 시 외부 URL은 필수입니다.',
];
}
}

View File

@@ -0,0 +1,321 @@
<?php
namespace App\Services;
use App\Models\Commons\Menu;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Support\Collection;
class MenuService
{
/**
* 메뉴 목록 조회 (페이지네이션) - 트리 구조로 정렬
*/
public function getMenus(array $filters = [], int $perPage = 15): LengthAwarePaginator
{
$tenantId = session('selected_tenant_id');
$query = Menu::query()->withTrashed();
// 테넌트 필터링
if ($tenantId) {
// 특정 테넌트 선택 시: 해당 테넌트의 메뉴만
$query->where('tenant_id', $tenantId);
} else {
// 전체 선택 시: tenant_id가 NULL인 마스터 메뉴만
$query->whereNull('tenant_id');
}
// 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']);
}
// 부모 메뉴 필터 (트리 구조)
if (isset($filters['parent_id'])) {
if ($filters['parent_id'] === 'null') {
$query->whereNull('parent_id');
} else {
$query->where('parent_id', $filters['parent_id']);
}
}
// 모든 메뉴 가져오기 (트리 구조 정렬을 위해)
$allMenus = $query->with(['parent', 'tenant'])->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()]
);
}
/**
* 트리 구조를 플랫한 배열로 변환 (depth 정보 포함)
*/
private function flattenMenuTree(Collection $menus, ?int $parentId = null, int $depth = 0): Collection
{
$result = collect();
$filteredMenus = $menus->where('parent_id', $parentId)->sortBy('sort_order');
foreach ($filteredMenus as $menu) {
$menu->depth = $depth;
// 자식 메뉴 존재 여부 확인
$menu->has_children = $menus->where('parent_id', $menu->id)->count() > 0;
$result->push($menu);
// 자식 메뉴 재귀적으로 추가
$children = $this->flattenMenuTree($menus, $menu->id, $depth + 1);
$result = $result->merge($children);
}
return $result;
}
/**
* 메뉴 상세 조회
*/
public function getMenuById(int $id): ?Menu
{
return Menu::with(['parent', 'children'])->find($id);
}
/**
* 메뉴 트리 구조로 조회 (전체)
*/
public function getMenuTree(?int $tenantId = null): Collection
{
$tenantId = $tenantId ?? session('selected_tenant_id');
$query = Menu::query()
->where('is_active', true)
->orderBy('sort_order')
->orderBy('id');
if ($tenantId) {
// 특정 테넌트 선택 시: 해당 테넌트의 메뉴만
$query->where('tenant_id', $tenantId);
} else {
// 전체 선택 시: tenant_id가 NULL인 마스터 메뉴만
$query->whereNull('tenant_id');
}
$allMenus = $query->get();
// 부모 메뉴만 필터링하고 자식 메뉴를 재귀적으로 연결
return $allMenus->where('parent_id', null)->map(function ($menu) use ($allMenus) {
$menu->children = $this->buildChildren($menu, $allMenus);
return $menu;
});
}
/**
* 재귀적으로 자식 메뉴 구성
*/
private function buildChildren(Menu $parent, Collection $allMenus): Collection
{
$children = $allMenus->where('parent_id', $parent->id);
return $children->map(function ($child) use ($allMenus) {
$child->children = $this->buildChildren($child, $allMenus);
return $child;
});
}
/**
* 부모 메뉴 목록 조회 (드롭다운용)
*/
public function getParentMenus(?int $tenantId = null): Collection
{
$tenantId = $tenantId ?? session('selected_tenant_id');
$query = Menu::query()
->where('is_active', true)
->orderBy('sort_order')
->orderBy('name');
if ($tenantId) {
// 특정 테넌트 선택 시: 해당 테넌트의 메뉴만
$query->where('tenant_id', $tenantId);
} else {
// 전체 선택 시: tenant_id가 NULL인 마스터 메뉴만
$query->whereNull('tenant_id');
}
return $query->get();
}
/**
* 메뉴 생성
*/
public function createMenu(array $data): Menu
{
$tenantId = session('selected_tenant_id');
// 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';
// 생성자 정보
$data['created_by'] = auth()->id();
// 테넌트 정보
if ($tenantId) {
$data['tenant_id'] = $tenantId;
}
// parent_id null 처리
if (empty($data['parent_id'])) {
$data['parent_id'] = null;
}
return Menu::create($data);
}
/**
* 메뉴 수정
*/
public function updateMenu(int $id, array $data): bool
{
$menu = $this->getMenuById($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';
// 수정자 정보
$data['updated_by'] = auth()->id();
// 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 deleteMenu(int $id): bool
{
$menu = $this->getMenuById($id);
if (! $menu) {
return false;
}
// 자식 메뉴가 있는 경우 삭제 불가
if ($menu->children()->count() > 0) {
return false;
}
$menu->deleted_by = auth()->id();
$menu->save();
return $menu->delete();
}
/**
* 메뉴 복원
*/
public function restoreMenu(int $id): bool
{
$menu = Menu::onlyTrashed()->findOrFail($id);
return $menu->restore();
}
/**
* 메뉴 영구 삭제 (슈퍼관리자 전용)
*/
public function forceDeleteMenu(int $id): bool
{
$menu = Menu::withTrashed()->findOrFail($id);
// 자식 메뉴가 있는 경우 영구 삭제 불가
if ($menu->children()->withTrashed()->count() > 0) {
return false;
}
return $menu->forceDelete();
}
/**
* 메뉴 활성 상태 토글
*/
public function toggleActive(int $id): bool
{
$menu = $this->getMenuById($id);
if (! $menu) {
return false;
}
$menu->is_active = ! $menu->is_active;
$menu->updated_by = auth()->id();
return $menu->save();
}
/**
* 메뉴 숨김 상태 토글
*/
public function toggleHidden(int $id): bool
{
$menu = $this->getMenuById($id);
if (! $menu) {
return false;
}
$menu->hidden = ! $menu->hidden;
$menu->updated_by = auth()->id();
return $menu->save();
}
}

View File

@@ -0,0 +1,171 @@
@extends('layouts.app')
@section('title', '메뉴 생성')
@section('content')
<div class="max-w-4xl mx-auto">
<!-- 페이지 헤더 -->
<div class="flex justify-between items-center mb-6">
<h1 class="text-2xl font-bold text-gray-800">📋 메뉴 생성</h1>
<a href="{{ route('menus.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/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-blue-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-blue-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-blue-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-blue-500"
placeholder="📋">
</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-blue-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-blue-600 border-gray-300 rounded focus:ring-blue-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-blue-600 border-gray-300 rounded focus:ring-blue-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-blue-600 border-gray-300 rounded focus:ring-blue-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-blue-500"
placeholder="https://example.com">
</div>
<!-- 버튼 그룹 -->
<div class="flex justify-end gap-4 mt-6">
<a href="{{ route('menus.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-blue-600 hover:bg-blue-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/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

View File

@@ -0,0 +1,184 @@
@extends('layouts.app')
@section('title', '메뉴 수정')
@section('content')
<div class="max-w-4xl mx-auto">
<!-- 페이지 헤더 -->
<div class="flex justify-between items-center mb-6">
<h1 class="text-2xl font-bold text-gray-800">📋 메뉴 수정</h1>
<a href="{{ route('menus.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/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-blue-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-blue-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-blue-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-blue-500"
placeholder="📋">
</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-blue-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-blue-600 border-gray-300 rounded focus:ring-blue-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-blue-600 border-gray-300 rounded focus:ring-blue-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-blue-600 border-gray-300 rounded focus:ring-blue-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-blue-500"
placeholder="https://example.com">
</div>
<!-- 버튼 그룹 -->
<div class="flex justify-end gap-4 mt-6">
<a href="{{ route('menus.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-blue-600 hover:bg-blue-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/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

View File

@@ -0,0 +1,194 @@
@extends('layouts.app')
@section('title', '메뉴 관리')
@section('content')
<!-- Tenant Selector -->
@include('partials.tenant-selector')
<!-- 페이지 헤더 -->
<div class="flex justify-between items-center mt-6 mb-6">
<h1 class="text-2xl font-bold text-gray-800">📋 메뉴 관리</h1>
<a href="{{ route('menus.create') }}" class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg transition">
+ 메뉴
</a>
</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-blue-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-blue-500">
<option value="">전체 상태</option>
<option value="1">활성</option>
<option value="0">비활성</option>
</select>
</div>
<!-- 검색 버튼 -->
<button type="submit" class="bg-gray-600 hover:bg-gray-700 text-white px-6 py-2 rounded-lg transition">
검색
</button>
</form>
</div>
<!-- 테이블 영역 (HTMX로 로드) -->
<div id="menu-table"
hx-get="/api/admin/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-blue-600"></div>
</div>
</div>
@endsection
@push('scripts')
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
<script>
// 폼 제출 시 HTMX 이벤트 트리거
document.getElementById('filterForm').addEventListener('submit', function(e) {
e.preventDefault();
htmx.trigger('#menu-table', 'filterSubmit');
});
// HTMX 응답 처리
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;
}
}
});
// 삭제 확인
window.confirmDelete = function(id, name) {
if (confirm(`"${name}" 메뉴를 삭제하시겠습니까?`)) {
htmx.ajax('DELETE', `/api/admin/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/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/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/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/menus/${id}/toggle-hidden`, {
target: '#menu-table',
swap: 'none',
headers: {
'X-CSRF-TOKEN': '{{ csrf_token() }}'
}
}).then(() => {
htmx.trigger('#menu-table', 'filterSubmit');
});
};
// 자식 메뉴 접기/펼치기
window.toggleChildren = function(menuId) {
const button = document.querySelector(`.toggle-btn[data-menu-id="${menuId}"]`);
const svg = button.querySelector('svg');
const isCollapsed = svg.classList.contains('rotate-[-90deg]');
if (isCollapsed) {
// 펼치기
svg.classList.remove('rotate-[-90deg]');
showChildren(menuId);
} else {
// 접기
svg.classList.add('rotate-[-90deg]');
hideChildren(menuId);
}
};
// 재귀적으로 자식 메뉴 숨기기
function hideChildren(parentId) {
const children = document.querySelectorAll(`tr.menu-row[data-parent-id="${parentId}"]`);
children.forEach(child => {
child.style.display = 'none';
const childId = child.getAttribute('data-menu-id');
// 하위의 하위도 숨기기
hideChildren(childId);
});
}
// 재귀적으로 직계 자식만 표시
function showChildren(parentId) {
const children = document.querySelectorAll(`tr.menu-row[data-parent-id="${parentId}"]`);
children.forEach(child => {
child.style.display = '';
// 하위 메뉴는 해당 메뉴가 펼쳐져 있을 때만 표시
const childId = child.getAttribute('data-menu-id');
const childButton = child.querySelector(`.toggle-btn[data-menu-id="${childId}"]`);
if (childButton) {
const childSvg = childButton.querySelector('svg');
// 자식이 접혀있으면 그 하위는 보여주지 않음
if (!childSvg.classList.contains('rotate-[-90deg]')) {
showChildren(childId);
}
}
});
}
</script>
@endpush

View File

@@ -0,0 +1,124 @@
<div class="bg-white rounded-lg shadow-sm overflow-hidden">
<table class="w-full">
<thead class="bg-gray-50 border-b">
<tr>
<th class="px-6 py-3 text-left text-sm font-semibold text-gray-700 uppercase tracking-wider">ID</th>
<th class="px-6 py-3 text-left text-sm font-semibold text-gray-700 uppercase tracking-wider">메뉴명</th>
<th class="px-6 py-3 text-left text-sm font-semibold text-gray-700 uppercase tracking-wider">URL</th>
<th class="px-6 py-3 text-left text-sm font-semibold text-gray-700 uppercase tracking-wider">정렬</th>
<th class="px-6 py-3 text-center text-sm font-semibold text-gray-700 uppercase tracking-wider">활성</th>
<th class="px-6 py-3 text-center text-sm font-semibold text-gray-700 uppercase tracking-wider">숨김</th>
<th class="px-6 py-3 text-left text-sm font-semibold text-gray-700 uppercase tracking-wider">작업</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
@forelse($menus as $menu)
<tr class="hover:bg-gray-50 menu-row"
data-menu-id="{{ $menu->id }}"
data-parent-id="{{ $menu->parent_id ?? '' }}"
data-depth="{{ $menu->depth ?? 0 }}">
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{{ $menu->id }}
</td>
<td class="px-6 py-4 whitespace-nowrap">
<div class="flex items-center" style="padding-left: {{ ($menu->depth ?? 0) * 20 }}px;">
@if($menu->has_children)
<button type="button"
onclick="toggleChildren({{ $menu->id }})"
class="mr-1 text-gray-500 hover:text-gray-700 focus:outline-none toggle-btn"
data-menu-id="{{ $menu->id }}">
<svg class="w-4 h-4 transform transition-transform" 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
<span class="w-4 mr-1"></span>
@endif
@if(($menu->depth ?? 0) > 0)
<span class="mr-2 text-gray-400"></span>
@endif
<div>
<div class="text-sm font-medium text-gray-900">{{ $menu->name }}</div>
@if($menu->is_external)
<span class="text-xs text-blue-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-blue-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-blue-500 focus:ring-offset-1 {{ $menu->is_active ? 'bg-blue-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>
@if(auth()->user()?->is_super_admin)
<button onclick="confirmForceDelete({{ $menu->id }}, '{{ $menu->name }}')"
class="text-red-600 hover:text-red-900">
영구삭제
</button>
@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>
@endif
</td>
</tr>
@empty
<tr>
<td colspan="7" class="px-6 py-4 text-center text-gray-500">
메뉴가 없습니다.
</td>
</tr>
@endforelse
</tbody>
</table>
</div>
<!-- 페이지네이션 -->
@include('partials.pagination', [
'paginator' => $menus,
'target' => '#menu-table',
'includeForm' => '#filterForm'
])

View File

@@ -1,6 +1,7 @@
<?php
use App\Http\Controllers\Api\Admin\DepartmentController;
use App\Http\Controllers\Api\Admin\MenuController;
use App\Http\Controllers\Api\Admin\RoleController;
use App\Http\Controllers\Api\Admin\TenantController;
use App\Http\Controllers\Api\Admin\UserController;
@@ -64,4 +65,23 @@
Route::post('/{id}/restore', [UserController::class, 'restore'])->name('restore');
Route::delete('/{id}/force', [UserController::class, 'forceDestroy'])->name('forceDestroy');
});
// 메뉴 관리 API
Route::prefix('menus')->name('menus.')->group(function () {
// 고정 경로는 먼저 정의
Route::get('/tree', [MenuController::class, 'tree'])->name('tree');
// 동적 경로는 나중에 정의
Route::get('/', [MenuController::class, 'index'])->name('index');
Route::post('/', [MenuController::class, 'store'])->name('store');
Route::get('/{id}', [MenuController::class, 'show'])->name('show');
Route::put('/{id}', [MenuController::class, 'update'])->name('update');
Route::delete('/{id}', [MenuController::class, 'destroy'])->name('destroy');
// 추가 액션
Route::post('/{id}/restore', [MenuController::class, 'restore'])->name('restore');
Route::delete('/{id}/force', [MenuController::class, 'forceDestroy'])->name('forceDestroy');
Route::post('/{id}/toggle-active', [MenuController::class, 'toggleActive'])->name('toggleActive');
Route::post('/{id}/toggle-hidden', [MenuController::class, 'toggleHidden'])->name('toggleHidden');
});
});

View File

@@ -2,6 +2,7 @@
use App\Http\Controllers\Auth\LoginController;
use App\Http\Controllers\DepartmentController;
use App\Http\Controllers\MenuController;
use App\Http\Controllers\RoleController;
use App\Http\Controllers\TenantController;
use App\Http\Controllers\UserController;
@@ -58,6 +59,13 @@
Route::get('/{id}/edit', [UserController::class, 'edit'])->name('edit');
});
// 메뉴 관리 (Blade 화면만)
Route::prefix('menus')->name('menus.')->group(function () {
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('/dashboard', function () {
return view('dashboard.index');