diff --git a/app/Http/Controllers/Api/Admin/BoardController.php b/app/Http/Controllers/Api/Admin/BoardController.php index 7030c162..1ac11b9c 100644 --- a/app/Http/Controllers/Api/Admin/BoardController.php +++ b/app/Http/Controllers/Api/Admin/BoardController.php @@ -281,4 +281,140 @@ public function reorderFields(Request $request, int $id): JsonResponse 'message' => '필드 순서가 변경되었습니다.', ]); } + + // ========================================================================= + // 템플릿 API + // ========================================================================= + + /** + * 템플릿 목록 조회 + */ + public function templates(Request $request): JsonResponse + { + $type = $request->get('type', 'all'); // system, tenant, all + + $templates = $this->boardService->getTemplates($type); + $baseFields = $this->boardService->getBaseFields(); + + return response()->json([ + 'success' => true, + 'data' => [ + 'templates' => $templates, + 'base_fields' => $baseFields, + ], + ]); + } + + /** + * 특정 템플릿 상세 조회 + */ + public function templateDetail(string $type, string $key): JsonResponse + { + $template = $this->boardService->getTemplate($type, $key); + + if (! $template) { + return response()->json([ + 'success' => false, + 'message' => '템플릿을 찾을 수 없습니다.', + ], 404); + } + + return response()->json([ + 'success' => true, + 'data' => $template, + ]); + } + + /** + * 템플릿 기반 게시판 생성 + */ + public function storeFromTemplate(Request $request): JsonResponse + { + $validated = $request->validate([ + 'board_code' => 'required|string|max:50', + 'name' => 'required|string|max:100', + 'template_type' => 'nullable|string|in:system,tenant', + 'template_key' => 'nullable|string|max:50', + 'tenant_id' => 'nullable|integer|exists:tenants,id', + 'board_type' => 'nullable|string|max:50', + 'description' => 'nullable|string|max:500', + 'editor_type' => 'nullable|string|in:wysiwyg,markdown,text', + 'allow_files' => 'nullable|boolean', + 'max_file_count' => 'nullable|integer|min:0|max:100', + 'max_file_size' => 'nullable|integer|min:0', + 'extra_settings' => 'nullable|array', + 'is_active' => 'nullable|boolean', + ]); + + // 코드 중복 체크 + if (! empty($validated['tenant_id'])) { + // 테넌트 게시판 + if ($this->boardService->isTenantCodeExists($validated['board_code'], $validated['tenant_id'])) { + return response()->json([ + 'success' => false, + 'message' => '해당 테넌트에서 이미 사용 중인 게시판 코드입니다.', + ], 422); + } + } else { + // 시스템 게시판 + if ($this->boardService->isCodeExists($validated['board_code'])) { + return response()->json([ + 'success' => false, + 'message' => '이미 사용 중인 게시판 코드입니다.', + ], 422); + } + } + + $board = $this->boardService->createBoardFromTemplate( + $validated, + $validated['template_type'] ?? null, + $validated['template_key'] ?? null + ); + + return response()->json([ + 'success' => true, + 'message' => '게시판이 생성되었습니다.', + 'data' => $board, + ]); + } + + // ========================================================================= + // 테넌트 관련 API + // ========================================================================= + + /** + * 테넌트 목록 조회 (드롭다운용) + */ + public function tenants(): JsonResponse + { + $tenants = $this->boardService->getTenantList(); + + return response()->json([ + 'success' => true, + 'data' => $tenants, + ]); + } + + /** + * 테넌트 게시판 코드 중복 체크 + */ + public function checkTenantCode(Request $request): JsonResponse + { + $validated = $request->validate([ + 'code' => 'required|string|max:50', + 'tenant_id' => 'required|integer|exists:tenants,id', + 'exclude_id' => 'nullable|integer', + ]); + + $exists = $this->boardService->isTenantCodeExists( + $validated['code'], + $validated['tenant_id'], + $validated['exclude_id'] ?? null + ); + + return response()->json([ + 'success' => true, + 'data' => ['exists' => $exists], + ]); + } } diff --git a/app/Services/BoardService.php b/app/Services/BoardService.php index 1e729a18..51c16ed1 100644 --- a/app/Services/BoardService.php +++ b/app/Services/BoardService.php @@ -4,8 +4,10 @@ use App\Models\Boards\Board; use App\Models\Boards\BoardSetting; +use App\Models\Tenants\Tenant; use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Database\Eloquent\Collection; +use Illuminate\Support\Facades\DB; class BoardService { @@ -268,4 +270,258 @@ public function getBoardTypes(): array ->pluck('board_type') ->toArray(); } + + // ========================================================================= + // 템플릿 관리 + // ========================================================================= + + /** + * 템플릿 목록 조회 + */ + public function getTemplates(string $type = 'all'): array + { + $templates = config('board_templates', []); + + if ($type === 'system') { + return $templates['system'] ?? []; + } + + if ($type === 'tenant') { + return $templates['tenant'] ?? []; + } + + return [ + 'system' => $templates['system'] ?? [], + 'tenant' => $templates['tenant'] ?? [], + ]; + } + + /** + * 특정 템플릿 조회 + */ + public function getTemplate(string $type, string $key): ?array + { + $templates = $this->getTemplates($type); + + return $templates[$key] ?? null; + } + + /** + * 기본 필드 목록 조회 + */ + public function getBaseFields(): array + { + return config('board_templates.base_fields', []); + } + + /** + * 템플릿 기반 게시판 생성 + */ + public function createBoardFromTemplate(array $data, ?string $templateType = null, ?string $templateKey = null): Board + { + return DB::transaction(function () use ($data, $templateType, $templateKey) { + // 템플릿 설정 적용 + $template = null; + if ($templateType && $templateKey) { + $template = $this->getTemplate($templateType, $templateKey); + if ($template) { + // 템플릿 기본값 적용 (사용자 입력값 우선) + $data = array_merge([ + 'board_type' => $template['board_type'] ?? null, + 'editor_type' => $template['editor_type'] ?? 'wysiwyg', + 'allow_files' => $template['allow_files'] ?? true, + 'max_file_count' => $template['max_file_count'] ?? 5, + 'max_file_size' => $template['max_file_size'] ?? 20480, + 'extra_settings' => $template['extra_settings'] ?? [], + ], $data); + } + } + + // 시스템/테넌트 구분 + if (isset($data['tenant_id']) && $data['tenant_id']) { + // 테넌트 게시판 + $data['is_system'] = false; + } else { + // 시스템 게시판 + $data['is_system'] = true; + $data['tenant_id'] = null; + } + + $data['created_by'] = auth()->id(); + + // 게시판 생성 + $board = Board::create($data); + + // 템플릿 기본 필드 생성 + if ($template && ! empty($template['default_fields'])) { + foreach ($template['default_fields'] as $index => $field) { + $field['board_id'] = $board->id; + $field['sort_order'] = $index + 1; + $field['created_by'] = auth()->id(); + BoardSetting::create($field); + } + } + + return $board->load('fields'); + }); + } + + // ========================================================================= + // 테넌트 게시판 관리 + // ========================================================================= + + /** + * 모든 게시판 목록 조회 (시스템 + 테넌트) + */ + public function getAllBoards(array $filters = [], int $perPage = 15): LengthAwarePaginator + { + $query = Board::query() + ->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']); + } + + // 검색 필터 + if (! empty($filters['search'])) { + $search = $filters['search']; + $query->where(function ($q) use ($search) { + $q->where('name', 'like', "%{$search}%") + ->orWhere('board_code', 'like', "%{$search}%") + ->orWhere('description', 'like', "%{$search}%"); + }); + } + + // 게시판 유형 필터 + if (! empty($filters['board_type'])) { + $query->where('board_type', $filters['board_type']); + } + + // 활성 상태 필터 + if (isset($filters['is_active']) && $filters['is_active'] !== '') { + $query->where('is_active', $filters['is_active']); + } + + // Soft Delete 필터 + if (isset($filters['trashed'])) { + if ($filters['trashed'] === 'only') { + $query->onlyTrashed(); + } elseif ($filters['trashed'] === 'with') { + $query->withTrashed(); + } + } + + // 정렬 + $sortBy = $filters['sort_by'] ?? 'id'; + $sortDirection = $filters['sort_direction'] ?? 'desc'; + $query->orderBy($sortBy, $sortDirection); + + return $query->paginate($perPage); + } + + /** + * 테넌트 목록 조회 (드롭다운용) + */ + public function getTenantList(): Collection + { + return Tenant::query() + ->active() + ->orderBy('company_name') + ->get(['id', 'code', 'company_name']); + } + + /** + * 테넌트 게시판 코드 중복 체크 + */ + public function isTenantCodeExists(string $code, int $tenantId, ?int $excludeId = null): bool + { + $query = Board::where('board_code', $code) + ->where('tenant_id', $tenantId); + + if ($excludeId) { + $query->where('id', '!=', $excludeId); + } + + return $query->exists(); + } + + /** + * 게시판 상세 조회 (시스템/테넌트 공통) + */ + public function getAnyBoardById(int $id, bool $withTrashed = false): ?Board + { + $query = Board::query() + ->with('fields') + ->withCount('fields'); + + if ($withTrashed) { + $query->withTrashed(); + } + + return $query->find($id); + } + + /** + * 게시판 수정 (시스템/테넌트 공통) + */ + public function updateAnyBoard(int $id, array $data): bool + { + $board = Board::findOrFail($id); + + $data['updated_by'] = auth()->id(); + + // tenant_id 변경 방지 (시스템 ↔ 테넌트 전환 불가) + unset($data['tenant_id'], $data['is_system']); + + return $board->update($data); + } + + /** + * 게시판 삭제 (시스템/테넌트 공통, Soft Delete) + */ + public function deleteAnyBoard(int $id): bool + { + $board = Board::findOrFail($id); + + $board->deleted_by = auth()->id(); + $board->save(); + + return $board->delete(); + } + + /** + * 게시판 복원 (시스템/테넌트 공통) + */ + public function restoreAnyBoard(int $id): bool + { + $board = Board::onlyTrashed()->findOrFail($id); + + $board->deleted_by = null; + + return $board->restore(); + } + + /** + * 게시판 영구 삭제 (시스템/테넌트 공통) + */ + public function forceDeleteAnyBoard(int $id): bool + { + $board = Board::withTrashed()->findOrFail($id); + + // 관련 필드 삭제 + $board->fields()->delete(); + + return $board->forceDelete(); + } } diff --git a/config/board_templates.php b/config/board_templates.php new file mode 100644 index 00000000..9becaf18 --- /dev/null +++ b/config/board_templates.php @@ -0,0 +1,290 @@ + [ + ['name' => '제목', 'key' => 'title', 'type' => 'text', 'required' => true], + ['name' => '내용', 'key' => 'content', 'type' => 'editor', 'required' => true], + ['name' => '작성자', 'key' => 'user_id', 'type' => 'auto', 'required' => true], + ['name' => '조회수', 'key' => 'views', 'type' => 'auto', 'required' => false], + ['name' => '공지 여부', 'key' => 'is_notice', 'type' => 'checkbox', 'required' => false], + ['name' => '비밀글 여부', 'key' => 'is_secret', 'type' => 'checkbox', 'required' => false], + ], + + /* + |-------------------------------------------------------------------------- + | 시스템 게시판 템플릿 + |-------------------------------------------------------------------------- + | 본사 ↔ 테넌트 소통용 게시판 + */ + 'system' => [ + + 'notice' => [ + 'name' => '공지사항', + 'description' => '본사에서 모든 테넌트에게 전달하는 중요 공지', + 'icon' => 'M11 5.882V19.24a1.76 1.76 0 01-3.417.592l-2.147-6.15M18 13a3 3 0 100-6M5.436 13.683A4.001 4.001 0 017 6h1.832c4.1 0 7.625-1.234 9.168-3v14c-1.543-1.766-5.067-3-9.168-3H7a3.988 3.988 0 01-1.564-.317z', + 'board_type' => 'notice', + 'editor_type' => 'wysiwyg', + 'allow_files' => true, + 'max_file_count' => 5, + 'max_file_size' => 20480, + 'extra_settings' => [ + 'allow_comment' => false, + 'allow_secret' => false, + 'write_roles' => ['admin', 'manager'], + 'read_roles' => ['*'], + ], + 'default_fields' => [ + [ + 'name' => '카테고리', + 'field_key' => 'category', + 'field_type' => 'select', + 'is_required' => false, + 'field_meta' => [ + 'options' => ['일반', '긴급', '점검', '업데이트'], + ], + ], + ], + ], + + 'qna' => [ + 'name' => '1:1 문의', + 'description' => '테넌트와 본사 간 1:1 질문/답변', + 'icon' => 'M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4', + 'board_type' => 'qna', + 'editor_type' => 'wysiwyg', + 'allow_files' => true, + 'max_file_count' => 10, + 'max_file_size' => 20480, + 'extra_settings' => [ + 'allow_comment' => true, + 'allow_secret' => true, + 'default_secret' => true, + 'only_author_view' => true, + 'write_roles' => ['*'], + 'read_roles' => ['*'], + ], + 'default_fields' => [ + [ + 'name' => '문의 유형', + 'field_key' => 'inquiry_type', + 'field_type' => 'select', + 'is_required' => true, + 'field_meta' => [ + 'options' => ['이용문의', '결제문의', '기능요청', '오류신고', '기타'], + ], + ], + [ + 'name' => '답변 상태', + 'field_key' => 'answer_status', + 'field_type' => 'select', + 'is_required' => false, + 'field_meta' => [ + 'options' => ['대기중', '답변완료'], + 'default' => '대기중', + ], + ], + ], + ], + + 'faq' => [ + 'name' => 'FAQ', + 'description' => '자주 묻는 질문과 답변', + 'icon' => 'M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z', + 'board_type' => 'faq', + 'editor_type' => 'wysiwyg', + 'allow_files' => false, + 'max_file_count' => 0, + 'max_file_size' => 0, + 'extra_settings' => [ + 'allow_comment' => false, + 'allow_secret' => false, + 'use_category' => true, + 'write_roles' => ['admin'], + 'read_roles' => ['*'], + ], + 'default_fields' => [ + [ + 'name' => '카테고리', + 'field_key' => 'category', + 'field_type' => 'select', + 'is_required' => true, + 'field_meta' => [ + 'options' => ['이용안내', '결제/환불', '기능안내', '계정관리', '기타'], + ], + ], + ], + ], + + 'popup' => [ + 'name' => '팝업 공지', + 'description' => '팝업으로 노출되는 긴급 공지', + 'icon' => 'M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9', + 'board_type' => 'popup', + 'editor_type' => 'wysiwyg', + 'allow_files' => true, + 'max_file_count' => 3, + 'max_file_size' => 10240, + 'extra_settings' => [ + 'allow_comment' => false, + 'allow_secret' => false, + 'use_period' => true, + 'write_roles' => ['admin'], + 'read_roles' => ['*'], + ], + 'default_fields' => [ + [ + 'name' => '노출 시작일', + 'field_key' => 'start_date', + 'field_type' => 'date', + 'is_required' => true, + 'field_meta' => [], + ], + [ + 'name' => '노출 종료일', + 'field_key' => 'end_date', + 'field_type' => 'date', + 'is_required' => true, + 'field_meta' => [], + ], + [ + 'name' => '팝업 위치', + 'field_key' => 'position', + 'field_type' => 'select', + 'is_required' => false, + 'field_meta' => [ + 'options' => ['중앙', '좌측상단', '우측상단', '좌측하단', '우측하단'], + 'default' => '중앙', + ], + ], + ], + ], + + ], + + /* + |-------------------------------------------------------------------------- + | 테넌트 게시판 템플릿 + |-------------------------------------------------------------------------- + | 각 테넌트 내부용 게시판 + */ + 'tenant' => [ + + 'free' => [ + 'name' => '자유게시판', + 'description' => '자유롭게 소통하는 게시판', + 'icon' => 'M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z', + 'board_type' => 'free', + 'editor_type' => 'wysiwyg', + 'allow_files' => true, + 'max_file_count' => 5, + 'max_file_size' => 20480, + 'extra_settings' => [ + 'allow_comment' => true, + 'allow_secret' => true, + 'write_roles' => ['*'], + 'read_roles' => ['*'], + ], + 'default_fields' => [], + ], + + 'gallery' => [ + 'name' => '갤러리', + 'description' => '이미지 중심의 게시판', + 'icon' => 'M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z', + 'board_type' => 'gallery', + 'editor_type' => 'wysiwyg', + 'allow_files' => true, + 'max_file_count' => 20, + 'max_file_size' => 51200, + 'extra_settings' => [ + 'allow_comment' => true, + 'allow_secret' => false, + 'thumbnail_size' => [200, 200], + 'allowed_extensions' => ['jpg', 'jpeg', 'png', 'gif', 'webp'], + 'write_roles' => ['*'], + 'read_roles' => ['*'], + ], + 'default_fields' => [], + ], + + 'download' => [ + 'name' => '자료실', + 'description' => '파일 공유를 위한 게시판', + 'icon' => 'M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z', + 'board_type' => 'download', + 'editor_type' => 'text', + 'allow_files' => true, + 'max_file_count' => 10, + 'max_file_size' => 102400, + 'extra_settings' => [ + 'allow_comment' => true, + 'allow_secret' => false, + 'write_roles' => ['admin', 'manager'], + 'read_roles' => ['*'], + ], + 'default_fields' => [ + [ + 'name' => '자료 유형', + 'field_key' => 'file_type', + 'field_type' => 'select', + 'is_required' => false, + 'field_meta' => [ + 'options' => ['매뉴얼', '양식', '보고서', '기타'], + ], + ], + ], + ], + + 'notice' => [ + 'name' => '공지사항', + 'description' => '테넌트 내부 공지사항', + 'icon' => 'M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-3 7h3m-3 4h3m-6-4h.01M9 16h.01', + 'board_type' => 'notice', + 'editor_type' => 'wysiwyg', + 'allow_files' => true, + 'max_file_count' => 5, + 'max_file_size' => 20480, + 'extra_settings' => [ + 'allow_comment' => false, + 'allow_secret' => false, + 'write_roles' => ['admin', 'manager'], + 'read_roles' => ['*'], + ], + 'default_fields' => [], + ], + + 'qna' => [ + 'name' => 'Q&A', + 'description' => '질문과 답변 게시판', + 'icon' => 'M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z', + 'board_type' => 'qna', + 'editor_type' => 'wysiwyg', + 'allow_files' => true, + 'max_file_count' => 5, + 'max_file_size' => 20480, + 'extra_settings' => [ + 'allow_comment' => true, + 'allow_secret' => true, + 'write_roles' => ['*'], + 'read_roles' => ['*'], + ], + 'default_fields' => [], + ], + + ], + +]; diff --git a/resources/views/boards/create.blade.php b/resources/views/boards/create.blade.php index 2688c84e..ea19c93c 100644 --- a/resources/views/boards/create.blade.php +++ b/resources/views/boards/create.blade.php @@ -5,17 +5,158 @@ @section('content')
-

📋 새 게시판 생성

+

새 게시판 생성

← 목록으로
- -
+ +
+
+
+
1
+ 게시판 유형 +
+
+
+
2
+ 템플릿 선택 +
+
+
+
3
+ 상세 설정 +
+
+
+ + +
+

게시판 유형을 선택하세요

+ +
+ +
+
+
+ + + +
+
+

시스템 게시판

+

모든 테넌트 공용

+
+
+

+ 본사에서 관리하며 모든 테넌트가 접근할 수 있는 게시판입니다. +
예: 공지사항, 1:1 문의, FAQ, 팝업 공지 +

+
+ + +
+
+
+ + + +
+
+

테넌트 게시판

+

특정 테넌트 전용

+
+
+

+ 특정 테넌트 내부에서만 사용하는 게시판입니다. +
예: 자유게시판, 갤러리, 자료실 +

+
+
+ + + +
+ + + + + +