diff --git a/app/Http/Controllers/Api/Admin/BoardController.php b/app/Http/Controllers/Api/Admin/BoardController.php index 1ac11b9c..1b916bb1 100644 --- a/app/Http/Controllers/Api/Admin/BoardController.php +++ b/app/Http/Controllers/Api/Admin/BoardController.php @@ -15,12 +15,18 @@ public function __construct( ) {} /** - * 게시판 목록 (HTMX용) + * 게시판 목록 (HTMX용) - 시스템 + 테넌트 게시판 모두 조회 */ public function index(Request $request): View|JsonResponse { - $filters = $request->only(['search', 'board_type', 'is_active', 'trashed', 'sort_by', 'sort_direction']); - $boards = $this->boardService->getBoards($filters, 15); + \Log::info('Board request all:', $request->all()); + \Log::info('Board request query:', $request->query()); + + $filters = $request->only(['search', 'board_type', 'tenant_id', 'is_active', 'trashed', 'sort_by', 'sort_direction']); + + \Log::info('Board filters:', $filters); + + $boards = $this->boardService->getAllBoards($filters, 15); // HTMX 요청이면 HTML 파셜 반환 if ($request->header('HX-Request')) { diff --git a/app/Http/Controllers/BoardController.php b/app/Http/Controllers/BoardController.php index e78cd77f..cea6f87d 100644 --- a/app/Http/Controllers/BoardController.php +++ b/app/Http/Controllers/BoardController.php @@ -17,8 +17,9 @@ public function __construct( public function index(): View { $boardTypes = $this->boardService->getBoardTypes(); + $tenants = $this->boardService->getTenantList(); - return view('boards.index', compact('boardTypes')); + return view('boards.index', compact('boardTypes', 'tenants')); } /** diff --git a/app/Models/Boards/Board.php b/app/Models/Boards/Board.php index 3e525eec..9ded5650 100644 --- a/app/Models/Boards/Board.php +++ b/app/Models/Boards/Board.php @@ -2,6 +2,7 @@ namespace App\Models\Boards; +use App\Models\Tenants\Tenant; use App\Models\User; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Model; @@ -109,6 +110,11 @@ public function posts(): HasMany return $this->hasMany(Post::class, 'board_id'); } + public function tenant(): BelongsTo + { + return $this->belongsTo(Tenant::class, 'tenant_id'); + } + public function creator(): BelongsTo { return $this->belongsTo(User::class, 'created_by'); diff --git a/app/Services/BoardService.php b/app/Services/BoardService.php index 51c16ed1..3da100e2 100644 --- a/app/Services/BoardService.php +++ b/app/Services/BoardService.php @@ -11,6 +11,10 @@ class BoardService { + public function __construct( + protected MenuService $menuService + ) {} + /** * 시스템 게시판 목록 조회 (페이지네이션) */ @@ -316,10 +320,12 @@ public function getBaseFields(): array /** * 템플릿 기반 게시판 생성 + * + * @param bool $createMenu 메뉴 자동 생성 여부 (기본: true) */ - public function createBoardFromTemplate(array $data, ?string $templateType = null, ?string $templateKey = null): Board + public function createBoardFromTemplate(array $data, ?string $templateType = null, ?string $templateKey = null, bool $createMenu = true): Board { - return DB::transaction(function () use ($data, $templateType, $templateKey) { + return DB::transaction(function () use ($data, $templateType, $templateKey, $createMenu) { // 템플릿 설정 적용 $template = null; if ($templateType && $templateKey) { @@ -362,6 +368,16 @@ public function createBoardFromTemplate(array $data, ?string $templateType = nul } } + // 메뉴 자동 생성 + if ($createMenu) { + $this->menuService->createMenuForBoard([ + 'board_code' => $board->board_code, + 'name' => $board->name, + 'is_system' => $board->is_system, + 'tenant_id' => $board->tenant_id, + ]); + } + return $board->load('fields'); }); } @@ -376,21 +392,15 @@ public function createBoardFromTemplate(array $data, ?string $templateType = nul public function getAllBoards(array $filters = [], int $perPage = 15): LengthAwarePaginator { $query = Board::query() + ->with('tenant:id,code,company_name') ->withCount('fields') ->withTrashed(); - // 게시판 유형 필터 (시스템/테넌트) - if (isset($filters['board_scope'])) { - if ($filters['board_scope'] === 'system') { - $query->where('is_system', true); - } elseif ($filters['board_scope'] === 'tenant') { - $query->where('is_system', false); - } - } - - // 테넌트 필터 + // 테넌트 필터: 선택 없으면 시스템만, 선택하면 해당 테넌트만 if (! empty($filters['tenant_id'])) { $query->where('tenant_id', $filters['tenant_id']); + } else { + $query->where('is_system', true); } // 검색 필터 @@ -489,6 +499,7 @@ public function updateAnyBoard(int $id, array $data): bool /** * 게시판 삭제 (시스템/테넌트 공통, Soft Delete) + * - 연결된 메뉴도 함께 Soft Delete */ public function deleteAnyBoard(int $id): bool { @@ -497,11 +508,19 @@ public function deleteAnyBoard(int $id): bool $board->deleted_by = auth()->id(); $board->save(); + // 연결된 메뉴도 함께 삭제 (Soft Delete) + $this->menuService->deleteMenuForBoard( + $board->board_code, + $board->is_system, + $board->tenant_id + ); + return $board->delete(); } /** * 게시판 복원 (시스템/테넌트 공통) + * - 연결된 메뉴도 함께 복원 */ public function restoreAnyBoard(int $id): bool { @@ -509,11 +528,25 @@ public function restoreAnyBoard(int $id): bool $board->deleted_by = null; - return $board->restore(); + // 게시판 복원 + $result = $board->restore(); + + // 메뉴가 없으면 다시 생성 + if ($result) { + $this->menuService->createMenuForBoard([ + 'board_code' => $board->board_code, + 'name' => $board->name, + 'is_system' => $board->is_system, + 'tenant_id' => $board->tenant_id, + ]); + } + + return $result; } /** * 게시판 영구 삭제 (시스템/테넌트 공통) + * - 연결된 메뉴도 함께 영구 삭제 */ public function forceDeleteAnyBoard(int $id): bool { @@ -522,6 +555,14 @@ public function forceDeleteAnyBoard(int $id): bool // 관련 필드 삭제 $board->fields()->delete(); + // 연결된 메뉴도 함께 영구 삭제 + $this->menuService->deleteMenuForBoard( + $board->board_code, + $board->is_system, + $board->tenant_id, + true // forceDelete + ); + return $board->forceDelete(); } } diff --git a/app/Services/MenuService.php b/app/Services/MenuService.php index c98db6d3..bec6ae50 100644 --- a/app/Services/MenuService.php +++ b/app/Services/MenuService.php @@ -15,6 +15,50 @@ class MenuService { + // ========================================================================= + // 게시판 메뉴 연동 헬퍼 + // ========================================================================= + + /** + * 게시판 연동 URL 패턴인지 확인 + * - /customer-center/* : 시스템 게시판 (고객센터) + * - /boards/* : 테넌트 게시판 + */ + public function isBoardMenuUrl(?string $url): bool + { + if (empty($url)) { + return false; + } + + return str_starts_with($url, '/customer-center/') || str_starts_with($url, '/boards/'); + } + + /** + * 게시판 연동 메뉴인지 확인 (Menu 또는 GlobalMenu) + */ + public function isBoardMenu(Menu|GlobalMenu $menu): bool + { + return $this->isBoardMenuUrl($menu->url); + } + + /** + * 게시판 메뉴 URL 수동 생성/수정 방지 검증 + * + * @throws \InvalidArgumentException 게시판 URL 패턴 사용 시 + */ + public function validateNotBoardUrl(?string $url): void + { + if ($this->isBoardMenuUrl($url)) { + throw new \InvalidArgumentException( + '게시판 연동 URL 패턴(/customer-center/*, /boards/*)은 직접 사용할 수 없습니다. 게시판 관리에서 생성해주세요.' + ); + } + } + + // ========================================================================= + // 메뉴 목록 조회 + // ========================================================================= + /** * 메뉴 목록 조회 (페이지네이션) - 트리 구조로 정렬 */ @@ -189,9 +233,14 @@ public function getParentMenus(?int $tenantId = null): Collection /** * 메뉴 생성 + * + * @throws \InvalidArgumentException 게시판 URL 패턴 사용 시 */ public function createMenu(array $data): Menu { + // 게시판 URL 패턴 수동 생성 방지 + $this->validateNotBoardUrl($data['url'] ?? null); + $tenantId = session('selected_tenant_id'); // is_active 처리 @@ -221,6 +270,8 @@ public function createMenu(array $data): Menu /** * 메뉴 수정 + * + * @throws \InvalidArgumentException 게시판 메뉴 수정 또는 게시판 URL 패턴 사용 시 */ public function updateMenu(int $id, array $data): bool { @@ -229,6 +280,16 @@ public function updateMenu(int $id, array $data): bool return false; } + // 게시판 연동 메뉴는 수정 불가 (활성/숨김 토글만 허용) + if ($this->isBoardMenu($menu)) { + throw new \InvalidArgumentException( + '게시판 연동 메뉴는 수정할 수 없습니다. 게시판 관리에서 수정해주세요.' + ); + } + + // 새 URL이 게시판 URL 패턴이면 거부 + $this->validateNotBoardUrl($data['url'] ?? null); + // is_active 처리 $data['is_active'] = isset($data['is_active']) && $data['is_active'] == '1'; @@ -256,6 +317,9 @@ public function updateMenu(int $id, array $data): bool /** * 메뉴 삭제 (Soft Delete) + + * + * @throws \InvalidArgumentException 게시판 메뉴 삭제 시도 시 */ public function deleteMenu(int $id): bool { @@ -264,6 +328,13 @@ public function deleteMenu(int $id): bool return false; } + // 게시판 연동 메뉴는 삭제 불가 + if ($this->isBoardMenu($menu)) { + throw new \InvalidArgumentException( + '게시판 연동 메뉴는 삭제할 수 없습니다. 게시판 관리에서 삭제해주세요.' + ); + } + // 자식 메뉴가 있는 경우 삭제 불가 if ($menu->children()->count() > 0) { return false; @@ -292,11 +363,20 @@ public function restoreMenu(int $id): bool * - 삭제 정보를 archived_records에 저장 * * @return array{success: bool, message: string, deleted_permissions: array} + * + * @throws \InvalidArgumentException 게시판 메뉴 영구 삭제 시도 시 */ public function forceDeleteMenu(int $id): array { $menu = Menu::withTrashed()->findOrFail($id); + // 게시판 연동 메뉴는 영구 삭제 불가 + if ($this->isBoardMenu($menu)) { + throw new \InvalidArgumentException( + '게시판 연동 메뉴는 영구 삭제할 수 없습니다. 게시판 관리에서 삭제해주세요.' + ); + } + // 자식 메뉴가 있는 경우 영구 삭제 불가 if ($menu->children()->withTrashed()->count() > 0) { return [ @@ -600,9 +680,14 @@ public function getGlobalMenus(array $filters = [], int $perPage = 15): LengthAw /** * 글로벌 메뉴 생성 + * + * @throws \InvalidArgumentException 게시판 URL 패턴 사용 시 */ public function createGlobalMenu(array $data): GlobalMenu { + // 게시판 URL 패턴 수동 생성 방지 + $this->validateNotBoardUrl($data['url'] ?? null); + // is_active 처리 $data['is_active'] = isset($data['is_active']) && $data['is_active'] == '1'; @@ -622,6 +707,8 @@ public function createGlobalMenu(array $data): GlobalMenu /** * 글로벌 메뉴 수정 + * + * @throws \InvalidArgumentException 게시판 메뉴 수정 또는 게시판 URL 패턴 사용 시 */ public function updateGlobalMenu(int $id, array $data): bool { @@ -630,6 +717,16 @@ public function updateGlobalMenu(int $id, array $data): bool return false; } + // 게시판 연동 메뉴는 수정 불가 (활성/숨김 토글만 허용) + if ($this->isBoardMenu($menu)) { + throw new \InvalidArgumentException( + '게시판 연동 메뉴는 수정할 수 없습니다. 게시판 관리에서 수정해주세요.' + ); + } + + // 새 URL이 게시판 URL 패턴이면 거부 + $this->validateNotBoardUrl($data['url'] ?? null); + // is_active 처리 $data['is_active'] = isset($data['is_active']) && $data['is_active'] == '1'; @@ -654,6 +751,8 @@ public function updateGlobalMenu(int $id, array $data): bool /** * 글로벌 메뉴 삭제 (Soft Delete) + * + * @throws \InvalidArgumentException 게시판 연동 메뉴인 경우 */ public function deleteGlobalMenu(int $id): bool { @@ -662,6 +761,13 @@ public function deleteGlobalMenu(int $id): bool return false; } + // 게시판 연동 메뉴는 삭제 불가 + if ($this->isBoardMenu($menu)) { + throw new \InvalidArgumentException( + '게시판 연동 메뉴는 삭제할 수 없습니다. 게시판 관리에서 삭제해주세요.' + ); + } + // 자식 메뉴가 있는 경우 삭제 불가 if ($menu->children()->count() > 0) { return false; @@ -687,11 +793,20 @@ public function restoreGlobalMenu(int $id): bool * - 삭제 정보를 archived_records에 저장 * * @return array{success: bool, message: string, deleted_permissions: array} + * + * @throws \InvalidArgumentException 게시판 연동 메뉴인 경우 */ public function forceDeleteGlobalMenu(int $id): array { $menu = GlobalMenu::withTrashed()->findOrFail($id); + // 게시판 연동 메뉴는 영구 삭제 불가 + if ($this->isBoardMenu($menu)) { + throw new \InvalidArgumentException( + '게시판 연동 메뉴는 영구 삭제할 수 없습니다. 게시판 관리에서 삭제해주세요.' + ); + } + // 자식 메뉴가 있는 경우 영구 삭제 불가 if ($menu->children()->withTrashed()->count() > 0) { return [ @@ -1045,4 +1160,184 @@ public function copyFromGlobal(int $tenantId, array $menuIds): array ]; }); } + + // ========================================================================= + // 게시판 메뉴 자동 생성 + // ========================================================================= + + /** + * URL로 부모 메뉴 찾기 (우선순위: /customer-center → /boards or /system-boards → null) + * + * @param bool $isSystem 시스템 게시판 여부 + * @param int|null $tenantId 테넌트 ID (시스템 게시판이면 null) + */ + public function findParentMenuForBoard(bool $isSystem, ?int $tenantId = null): ?int + { + // 우선순위 URL 목록 + $priorityUrls = ['/customer-center']; + + if ($isSystem) { + // 시스템 게시판: global_menus에서 찾기 + $priorityUrls[] = '/system-boards'; + $priorityUrls[] = '/boards'; + + foreach ($priorityUrls as $url) { + $menu = GlobalMenu::where('url', $url) + ->where('is_active', true) + ->first(); + + if ($menu) { + return $menu->id; + } + } + + return null; // 최상위로 추가 + } else { + // 테넌트 게시판: menus에서 찾기 + $priorityUrls[] = '/boards'; + + foreach ($priorityUrls as $url) { + $query = Menu::where('url', $url) + ->where('is_active', true); + + if ($tenantId) { + $query->where('tenant_id', $tenantId); + } else { + $query->whereNull('tenant_id'); + } + + $menu = $query->first(); + + if ($menu) { + return $menu->id; + } + } + + return null; // 최상위로 추가 + } + } + + /** + * 게시판에 대한 메뉴 자동 생성 + * + * @param array $boardData 게시판 정보 (board_code, name, is_system, tenant_id) + * @return GlobalMenu|Menu|null 생성된 메뉴 또는 null + */ + public function createMenuForBoard(array $boardData): GlobalMenu|Menu|null + { + $isSystem = $boardData['is_system'] ?? false; + $tenantId = $boardData['tenant_id'] ?? null; + $boardCode = $boardData['board_code']; + $boardName = $boardData['name']; + + // 부모 메뉴 찾기 + $parentId = $this->findParentMenuForBoard($isSystem, $tenantId); + + if ($isSystem) { + // 시스템 게시판 → global_menus에 추가 + $url = '/customer-center/'.$boardCode; + + // 중복 체크 + $exists = GlobalMenu::where('url', $url)->exists(); + if ($exists) { + return null; + } + + // 정렬 순서 계산 + $maxOrder = GlobalMenu::where('parent_id', $parentId)->max('sort_order') ?? 0; + + return GlobalMenu::create([ + 'parent_id' => $parentId, + 'name' => $boardName, + 'url' => $url, + 'icon' => 'document-text', // 기본 아이콘 + 'sort_order' => $maxOrder + 1, + 'is_active' => true, + 'hidden' => false, + 'is_external' => false, + ]); + } else { + // 테넌트 게시판 → menus에 추가 + $url = '/boards/'.$boardCode; + + // 중복 체크 + $query = Menu::where('url', $url); + if ($tenantId) { + $query->where('tenant_id', $tenantId); + } else { + $query->whereNull('tenant_id'); + } + + if ($query->exists()) { + return null; + } + + // 정렬 순서 계산 + $maxOrderQuery = Menu::where('parent_id', $parentId); + if ($tenantId) { + $maxOrderQuery->where('tenant_id', $tenantId); + } else { + $maxOrderQuery->whereNull('tenant_id'); + } + $maxOrder = $maxOrderQuery->max('sort_order') ?? 0; + + return Menu::create([ + 'tenant_id' => $tenantId, + 'parent_id' => $parentId, + 'name' => $boardName, + 'url' => $url, + 'icon' => 'document-text', // 기본 아이콘 + 'sort_order' => $maxOrder + 1, + 'is_active' => true, + 'hidden' => false, + 'is_external' => false, + 'created_by' => auth()->id(), + ]); + } + } + + /** + * 게시판 삭제 시 연결된 메뉴도 삭제 + * + * @param string $boardCode 게시판 코드 + * @param bool $isSystem 시스템 게시판 여부 + * @param int|null $tenantId 테넌트 ID + */ + public function deleteMenuForBoard(string $boardCode, bool $isSystem, ?int $tenantId = null, bool $forceDelete = false): bool + { + if ($isSystem) { + $url = '/customer-center/'.$boardCode; + $menu = $forceDelete + ? GlobalMenu::withTrashed()->where('url', $url)->first() + : GlobalMenu::where('url', $url)->first(); + + if ($menu) { + return $forceDelete ? $menu->forceDelete() : $menu->delete(); + } + } else { + $url = '/boards/'.$boardCode; + $query = $forceDelete + ? Menu::withTrashed()->where('url', $url) + : Menu::where('url', $url); + + if ($tenantId) { + $query->where('tenant_id', $tenantId); + } else { + $query->whereNull('tenant_id'); + } + + $menu = $query->first(); + + if ($menu) { + if (! $forceDelete) { + $menu->deleted_by = auth()->id(); + $menu->save(); + } + + return $forceDelete ? $menu->forceDelete() : $menu->delete(); + } + } + + return false; + } } diff --git a/resources/views/boards/index.blade.php b/resources/views/boards/index.blade.php index f2036d1d..c2410344 100644 --- a/resources/views/boards/index.blade.php +++ b/resources/views/boards/index.blade.php @@ -1,18 +1,18 @@ @extends('layouts.app') -@section('title', '시스템 게시판 관리') +@section('title', '게시판 관리') @section('content')
-

시스템 게시판 관리

+

게시판 관리

+ 새 게시판
- +
@@ -22,6 +22,16 @@ class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
+ +
+ +
+