메뉴 관리 기능 개선
- 트리 구조 정렬 및 표시 (무제한 depth 지원) - 접기/펼치기 기능 추가 (재귀적 처리) - 활성/숨김 상태 토글 기능 (실시간 업데이트) - 테넌트 필터링 (전체 선택 시 마스터 메뉴만 표시) - UI 개선 (토글 버튼, 외부 메뉴 표시) 추가된 파일: - MenuService: 비즈니스 로직 처리 - MenuController (API/Web): 라우트 처리 - MenuRequest: 유효성 검증 - views/menus/: 메뉴 관리 뷰 수정된 파일: - routes/api.php, web.php: 메뉴 라우트 추가
This commit is contained in:
295
app/Http/Controllers/Api/Admin/MenuController.php
Normal file
295
app/Http/Controllers/Api/Admin/MenuController.php
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
48
app/Http/Controllers/MenuController.php
Normal file
48
app/Http/Controllers/MenuController.php
Normal 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'));
|
||||
}
|
||||
}
|
||||
69
app/Http/Requests/StoreMenuRequest.php
Normal file
69
app/Http/Requests/StoreMenuRequest.php
Normal 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은 필수입니다.',
|
||||
];
|
||||
}
|
||||
}
|
||||
80
app/Http/Requests/UpdateMenuRequest.php
Normal file
80
app/Http/Requests/UpdateMenuRequest.php
Normal 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은 필수입니다.',
|
||||
];
|
||||
}
|
||||
}
|
||||
321
app/Services/MenuService.php
Normal file
321
app/Services/MenuService.php
Normal 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();
|
||||
}
|
||||
}
|
||||
171
resources/views/menus/create.blade.php
Normal file
171
resources/views/menus/create.blade.php
Normal 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
|
||||
184
resources/views/menus/edit.blade.php
Normal file
184
resources/views/menus/edit.blade.php
Normal 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
|
||||
194
resources/views/menus/index.blade.php
Normal file
194
resources/views/menus/index.blade.php
Normal 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
|
||||
124
resources/views/menus/partials/table.blade.php
Normal file
124
resources/views/menus/partials/table.blade.php
Normal 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'
|
||||
])
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user