feat: 게시판 템플릿 기반 생성 기능 및 SVG 아이콘 적용

- 게시판 템플릿 설정 파일 추가 (config/board_templates.php)
  - 시스템 템플릿: 공지사항, 1:1문의, FAQ, 팝업공지
  - 테넌트 템플릿: 자유게시판, 갤러리, 자료실, 공지사항, Q&A
- BoardService 템플릿 관련 메서드 추가
- BoardController 템플릿 API 엔드포인트 추가
- 게시판 생성 UI 3단계 위자드로 개선
- 모든 템플릿 아이콘을 이모지에서 SVG path로 변경

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-12-01 23:27:37 +09:00
parent d992a19735
commit 817690f544
5 changed files with 1187 additions and 37 deletions

View File

@@ -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],
]);
}
}

View File

@@ -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();
}
}

290
config/board_templates.php Normal file
View File

@@ -0,0 +1,290 @@
<?php
/**
* 게시판 템플릿 설정
*
* 시스템 게시판: 본사 ↔ 테넌트 소통용 (모든 테넌트 공용)
* 테넌트 게시판: 각 테넌트 내부용
*/
return [
/*
|--------------------------------------------------------------------------
| 기본 필드 안내 (모든 게시판 공통)
|--------------------------------------------------------------------------
| 게시글 작성 시 자동으로 포함되는 필드들
*/
'base_fields' => [
['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' => [],
],
],
];

View File

@@ -5,17 +5,158 @@
@section('content')
<!-- 페이지 헤더 -->
<div class="flex justify-between items-center mb-6">
<h1 class="text-2xl font-bold text-gray-800">📋 게시판 생성</h1>
<h1 class="text-2xl font-bold text-gray-800"> 게시판 생성</h1>
<a href="{{ route('boards.index') }}" class="text-gray-600 hover:text-gray-900">
&larr; 목록으로
</a>
</div>
<!-- 영역 -->
<div class="bg-white rounded-lg shadow-sm p-6">
<!-- 스텝 인디케이터 -->
<div class="mb-8">
<div class="flex items-center justify-center space-x-4">
<div id="step1-indicator" class="flex items-center">
<div class="w-8 h-8 rounded-full bg-blue-600 text-white flex items-center justify-center text-sm font-medium">1</div>
<span class="ml-2 text-sm font-medium text-blue-600">게시판 유형</span>
</div>
<div class="w-16 h-0.5 bg-gray-300" id="step-line-1"></div>
<div id="step2-indicator" class="flex items-center">
<div class="w-8 h-8 rounded-full bg-gray-300 text-gray-600 flex items-center justify-center text-sm font-medium">2</div>
<span class="ml-2 text-sm font-medium text-gray-500">템플릿 선택</span>
</div>
<div class="w-16 h-0.5 bg-gray-300" id="step-line-2"></div>
<div id="step3-indicator" class="flex items-center">
<div class="w-8 h-8 rounded-full bg-gray-300 text-gray-600 flex items-center justify-center text-sm font-medium">3</div>
<span class="ml-2 text-sm font-medium text-gray-500">상세 설정</span>
</div>
</div>
</div>
<!-- Step 1: 게시판 유형 선택 -->
<div id="step1" class="bg-white rounded-lg shadow-sm p-6">
<h2 class="text-lg font-medium text-gray-900 mb-6">게시판 유형을 선택하세요</h2>
<div class="grid grid-cols-2 gap-6">
<!-- 시스템 게시판 -->
<div class="border-2 border-gray-200 rounded-lg p-6 cursor-pointer hover:border-blue-500 hover:bg-blue-50 transition"
onclick="selectBoardScope('system')" id="scope-system">
<div class="flex items-center mb-4">
<div class="w-12 h-12 bg-blue-100 rounded-lg flex items-center justify-center">
<svg class="w-6 h-6 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4"></path>
</svg>
</div>
<div class="ml-4">
<h3 class="text-lg font-semibold text-gray-900">시스템 게시판</h3>
<p class="text-sm text-gray-500">모든 테넌트 공용</p>
</div>
</div>
<p class="text-sm text-gray-600">
본사에서 관리하며 모든 테넌트가 접근할 있는 게시판입니다.
<br>: 공지사항, 1:1 문의, FAQ, 팝업 공지
</p>
</div>
<!-- 테넌트 게시판 -->
<div class="border-2 border-gray-200 rounded-lg p-6 cursor-pointer hover:border-green-500 hover:bg-green-50 transition"
onclick="selectBoardScope('tenant')" id="scope-tenant">
<div class="flex items-center mb-4">
<div class="w-12 h-12 bg-green-100 rounded-lg flex items-center justify-center">
<svg class="w-6 h-6 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"></path>
</svg>
</div>
<div class="ml-4">
<h3 class="text-lg font-semibold text-gray-900">테넌트 게시판</h3>
<p class="text-sm text-gray-500">특정 테넌트 전용</p>
</div>
</div>
<p class="text-sm text-gray-600">
특정 테넌트 내부에서만 사용하는 게시판입니다.
<br>: 자유게시판, 갤러리, 자료실
</p>
</div>
</div>
<!-- 테넌트 선택 (테넌트 게시판 선택 표시) -->
<div id="tenant-selector" class="mt-6 hidden">
<label class="block text-sm font-medium text-gray-700 mb-2">
테넌트 선택 <span class="text-red-500">*</span>
</label>
<select id="tenant_id" class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-green-500">
<option value="">테넌트를 선택하세요</option>
</select>
</div>
</div>
<!-- Step 2: 템플릿 선택 -->
<div id="step2" class="bg-white rounded-lg shadow-sm p-6 hidden">
<h2 class="text-lg font-medium text-gray-900 mb-2">템플릿을 선택하세요</h2>
<p class="text-sm text-gray-500 mb-6">템플릿을 선택하면 기본 설정과 필드가 자동으로 구성됩니다. 템플릿으로 직접 구성할 수도 있습니다.</p>
<!-- 시스템 템플릿 -->
<div id="system-templates" class="hidden">
<h3 class="text-sm font-medium text-gray-700 mb-4">시스템 게시판 템플릿</h3>
<div class="grid grid-cols-4 gap-4" id="system-template-cards"></div>
</div>
<!-- 테넌트 템플릿 -->
<div id="tenant-templates" class="hidden">
<h3 class="text-sm font-medium text-gray-700 mb-4">테넌트 게시판 템플릿</h3>
<div class="grid grid-cols-4 gap-4" id="tenant-template-cards"></div>
</div>
<!-- 템플릿 -->
<div class="mt-6 pt-6 border-t border-gray-200">
<div class="border-2 border-dashed border-gray-300 rounded-lg p-4 cursor-pointer hover:border-gray-400 hover:bg-gray-50 transition text-center"
onclick="selectTemplate(null, null)">
<div class="text-gray-400 text-2xl mb-2">+</div>
<div class="text-sm font-medium text-gray-600"> 템플릿</div>
<div class="text-xs text-gray-400">직접 모든 설정 구성</div>
</div>
</div>
<!-- 뒤로 버튼 -->
<div class="mt-6 flex justify-start">
<button type="button" onclick="goToStep(1)" class="px-4 py-2 text-gray-600 hover:text-gray-900">
&larr; 이전 단계
</button>
</div>
</div>
<!-- Step 3: 상세 설정 -->
<div id="step3" class="bg-white rounded-lg shadow-sm p-6 hidden">
<form id="boardForm" class="space-y-6">
@csrf
<!-- 선택된 정보 표시 -->
<div class="bg-gray-50 rounded-lg p-4 mb-6">
<div class="flex items-center justify-between">
<div>
<span class="text-sm text-gray-500">선택된 유형:</span>
<span id="selected-scope-label" class="ml-2 font-medium text-gray-900"></span>
<span id="selected-tenant-label" class="ml-2 text-sm text-gray-600"></span>
</div>
<div>
<span class="text-sm text-gray-500">템플릿:</span>
<span id="selected-template-label" class="ml-2 font-medium text-gray-900"></span>
</div>
</div>
</div>
<!-- 기본 필드 안내 -->
<div class="bg-blue-50 border border-blue-200 rounded-lg p-4">
<h3 class="text-sm font-medium text-blue-800 mb-2">기본 필드 (자동 포함)</h3>
<div class="grid grid-cols-3 gap-2 text-sm text-blue-700">
<div>- 제목 (title) <span class="text-red-500">*</span></div>
<div>- 내용 (content) <span class="text-red-500">*</span></div>
<div>- 작성자 (user_id)</div>
<div>- 조회수 (views)</div>
<div>- 공지 여부 (is_notice)</div>
<div>- 비밀글 여부 (is_secret)</div>
</div>
<p class="mt-2 text-xs text-blue-600"> 필드는 모든 게시판에 기본으로 포함됩니다. 아래에서 추가 커스텀 필드를 설정할 있습니다.</p>
</div>
<!-- 기본 정보 -->
<div class="border-b border-gray-200 pb-6">
<h2 class="text-lg font-medium text-gray-900 mb-4">기본 정보</h2>
@@ -26,7 +167,7 @@
<label class="block text-sm font-medium text-gray-700 mb-2">
게시판 코드 <span class="text-red-500">*</span>
</label>
<input type="text" name="board_code"
<input type="text" name="board_code" id="board_code"
placeholder="영문 소문자, 숫자, 하이픈만 사용"
pattern="[a-z0-9-]+"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
@@ -39,7 +180,7 @@ class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none foc
<label class="block text-sm font-medium text-gray-700 mb-2">
게시판명 <span class="text-red-500">*</span>
</label>
<input type="text" name="name"
<input type="text" name="name" id="board_name"
placeholder="공지사항"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
required>
@@ -48,24 +189,15 @@ class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none foc
<!-- 게시판 유형 -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">게시판 유형</label>
<input type="text" name="board_type"
<input type="text" name="board_type" id="board_type"
placeholder="notice, qna, faq, free 등"
list="boardTypeList"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
<datalist id="boardTypeList">
<option value="notice">공지사항</option>
<option value="qna">1:1 문의</option>
<option value="faq">FAQ</option>
<option value="free">자유게시판</option>
<option value="gallery">갤러리</option>
<option value="download">자료실</option>
</datalist>
</div>
<!-- 에디터 타입 -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">에디터 타입</label>
<select name="editor_type"
<select name="editor_type" id="editor_type"
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="wysiwyg">WYSIWYG (위지윅)</option>
<option value="markdown">Markdown</option>
@@ -76,7 +208,7 @@ class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none foc
<!-- 설명 -->
<div class="col-span-2">
<label class="block text-sm font-medium text-gray-700 mb-2">설명</label>
<textarea name="description" rows="3"
<textarea name="description" id="description" rows="3"
placeholder="게시판에 대한 간단한 설명"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"></textarea>
</div>
@@ -91,7 +223,7 @@ class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none foc
<!-- 파일 첨부 허용 -->
<div>
<label class="flex items-center">
<input type="checkbox" name="allow_files" value="1" checked
<input type="checkbox" name="allow_files" id="allow_files" value="1" checked
class="rounded border-gray-300 text-blue-600 focus:ring-blue-500">
<span class="ml-2 text-sm text-gray-700">파일 첨부 허용</span>
</label>
@@ -100,24 +232,31 @@ class="rounded border-gray-300 text-blue-600 focus:ring-blue-500">
<!-- 최대 파일 -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">최대 파일 </label>
<input type="number" name="max_file_count" value="5" min="0" max="100"
<input type="number" name="max_file_count" id="max_file_count" value="5" min="0" max="100"
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>
<label class="block text-sm font-medium text-gray-700 mb-2">최대 파일 크기 (KB)</label>
<input type="number" name="max_file_size" value="20480" min="0"
<input type="number" name="max_file_size" id="max_file_size" value="20480" 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">
<p class="mt-1 text-sm text-gray-500">20480 = 20MB</p>
</div>
</div>
</div>
<!-- 템플릿 기본 필드 (템플릿 선택 표시) -->
<div id="template-fields-section" class="border-b border-gray-200 pb-6 hidden">
<h2 class="text-lg font-medium text-gray-900 mb-4">커스텀 필드 (템플릿 기본값)</h2>
<p class="text-sm text-gray-500 mb-4">선택한 템플릿에 포함된 기본 커스텀 필드입니다. 게시판 생성 수정/추가할 있습니다.</p>
<div id="template-fields-list" class="space-y-2"></div>
</div>
<!-- 활성 상태 -->
<div>
<label class="flex items-center">
<input type="checkbox" name="is_active" value="1" checked
<input type="checkbox" name="is_active" id="is_active" value="1" checked
class="rounded border-gray-300 text-blue-600 focus:ring-blue-500">
<span class="ml-2 text-sm text-gray-700">활성화</span>
</label>
@@ -127,7 +266,11 @@ class="rounded border-gray-300 text-blue-600 focus:ring-blue-500">
<div id="errorMessage" class="hidden bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg"></div>
<!-- 버튼 -->
<div class="flex justify-end space-x-4">
<div class="flex justify-between">
<button type="button" onclick="goToStep(2)" class="px-4 py-2 text-gray-600 hover:text-gray-900">
&larr; 이전 단계
</button>
<div class="space-x-4">
<a href="{{ route('boards.index') }}"
class="px-6 py-2 border border-gray-300 rounded-lg text-gray-700 hover:bg-gray-50 transition">
취소
@@ -137,38 +280,337 @@ class="px-6 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition"
생성
</button>
</div>
</div>
</form>
</div>
<!-- 생성 완료 모달 -->
<div id="successModal" class="fixed inset-0 bg-black bg-opacity-50 hidden items-center justify-center z-50">
<div class="bg-white rounded-lg shadow-xl max-w-md w-full mx-4 p-6">
<div class="text-center">
<div class="w-16 h-16 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-4">
<svg class="w-8 h-8 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
</svg>
</div>
<h3 class="text-lg font-semibold text-gray-900 mb-2">게시판이 생성되었습니다!</h3>
<p class="text-sm text-gray-600 mb-4" id="success-board-name"></p>
</div>
<div class="bg-gray-50 rounded-lg p-4 mb-4">
<h4 class="text-sm font-medium text-gray-700 mb-2">API 엔드포인트</h4>
<div class="text-xs font-mono bg-white rounded p-2 border border-gray-200">
<div class="mb-1"><span class="text-green-600">GET</span> /api/v1/boards/<span id="success-board-code" class="text-blue-600"></span></div>
<div class="mb-1"><span class="text-blue-600">POST</span> /api/v1/boards/<span class="board-code-placeholder text-blue-600"></span>/posts</div>
<div><span class="text-green-600">GET</span> /api/v1/boards/<span class="board-code-placeholder text-blue-600"></span>/posts</div>
</div>
</div>
<div class="flex space-x-3">
<a href="{{ route('boards.index') }}" class="flex-1 px-4 py-2 border border-gray-300 rounded-lg text-gray-700 hover:bg-gray-50 text-center">
목록으로
</a>
<button onclick="closeSuccessModal()" class="flex-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg">
커스텀 필드 추가
</button>
</div>
</div>
</div>
@endsection
@push('scripts')
<script>
// 상태 변수
let currentStep = 1;
let selectedScope = null; // 'system' or 'tenant'
let selectedTenantId = null;
let selectedTemplateType = null;
let selectedTemplateKey = null;
let templates = { system: {}, tenant: {} };
let tenants = [];
let createdBoardId = null;
// 초기화
document.addEventListener('DOMContentLoaded', async function() {
await loadTemplates();
await loadTenants();
});
// 템플릿 로드
async function loadTemplates() {
try {
const response = await fetch('/api/admin/boards/templates', {
headers: { 'Accept': 'application/json' }
});
const data = await response.json();
if (data.success) {
templates = data.data.templates;
renderTemplateCards();
}
} catch (error) {
console.error('템플릿 로드 실패:', error);
}
}
// 테넌트 목록 로드
async function loadTenants() {
try {
const response = await fetch('/api/admin/boards/tenants', {
headers: { 'Accept': 'application/json' }
});
const data = await response.json();
if (data.success) {
tenants = data.data;
renderTenantOptions();
}
} catch (error) {
console.error('테넌트 로드 실패:', error);
}
}
// 테넌트 옵션 렌더링
function renderTenantOptions() {
const select = document.getElementById('tenant_id');
tenants.forEach(tenant => {
const option = document.createElement('option');
option.value = tenant.id;
option.textContent = `${tenant.company_name} (${tenant.code})`;
select.appendChild(option);
});
}
// 템플릿 카드 렌더링
function renderTemplateCards() {
// 시스템 템플릿
const systemContainer = document.getElementById('system-template-cards');
systemContainer.innerHTML = '';
Object.entries(templates.system || {}).forEach(([key, template]) => {
systemContainer.innerHTML += createTemplateCard('system', key, template);
});
// 테넌트 템플릿
const tenantContainer = document.getElementById('tenant-template-cards');
tenantContainer.innerHTML = '';
Object.entries(templates.tenant || {}).forEach(([key, template]) => {
tenantContainer.innerHTML += createTemplateCard('tenant', key, template);
});
}
// 템플릿 카드 HTML 생성
function createTemplateCard(type, key, template) {
const colorClass = type === 'system' ? 'blue' : 'green';
const defaultIconPath = 'M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z';
const iconPath = template.icon || defaultIconPath;
return `
<div class="border-2 border-gray-200 rounded-lg p-4 cursor-pointer hover:border-${colorClass}-500 hover:bg-${colorClass}-50 transition text-center"
onclick="selectTemplate('${type}', '${key}')">
<div class="mb-2">
<svg class="w-8 h-8 mx-auto text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="${iconPath}" />
</svg>
</div>
<div class="text-sm font-medium text-gray-900">${template.name}</div>
<div class="text-xs text-gray-500 mt-1">${template.description?.substring(0, 30) || ''}...</div>
${template.default_fields?.length ? `<div class="text-xs text-${colorClass}-600 mt-2">필드 ${template.default_fields.length} 포함</div>` : ''}
</div>
`;
}
// 게시판 유형 선택
function selectBoardScope(scope) {
selectedScope = scope;
// UI 업데이트
document.getElementById('scope-system').classList.remove('border-blue-500', 'bg-blue-50');
document.getElementById('scope-tenant').classList.remove('border-green-500', 'bg-green-50');
if (scope === 'system') {
document.getElementById('scope-system').classList.add('border-blue-500', 'bg-blue-50');
document.getElementById('tenant-selector').classList.add('hidden');
selectedTenantId = null;
goToStep(2);
} else {
document.getElementById('scope-tenant').classList.add('border-green-500', 'bg-green-50');
document.getElementById('tenant-selector').classList.remove('hidden');
}
}
// 테넌트 선택 이벤트
document.getElementById('tenant_id')?.addEventListener('change', function(e) {
selectedTenantId = e.target.value;
if (selectedTenantId) {
goToStep(2);
}
});
// 템플릿 선택
function selectTemplate(type, key) {
selectedTemplateType = type;
selectedTemplateKey = key;
// 템플릿 설정 적용
if (type && key && templates[type]?.[key]) {
const template = templates[type][key];
applyTemplateSettings(template);
} else {
// 빈 템플릿 - 기본값
resetFormToDefaults();
}
goToStep(3);
}
// 템플릿 설정 적용
function applyTemplateSettings(template) {
document.getElementById('board_code').value = template.board_type || '';
document.getElementById('board_name').value = template.name || '';
document.getElementById('board_type').value = template.board_type || '';
document.getElementById('description').value = template.description || '';
document.getElementById('editor_type').value = template.editor_type || 'wysiwyg';
document.getElementById('allow_files').checked = template.allow_files !== false;
document.getElementById('max_file_count').value = template.max_file_count || 5;
document.getElementById('max_file_size').value = template.max_file_size || 20480;
// 템플릿 기본 필드 표시
if (template.default_fields?.length) {
document.getElementById('template-fields-section').classList.remove('hidden');
const fieldsList = document.getElementById('template-fields-list');
fieldsList.innerHTML = template.default_fields.map(field => `
<div class="flex items-center justify-between bg-gray-50 rounded px-4 py-2">
<div>
<span class="font-medium text-gray-900">${field.name}</span>
<span class="text-gray-500 text-sm ml-2">(${field.field_key})</span>
</div>
<div class="flex items-center space-x-4 text-sm">
<span class="text-gray-600">${field.field_type}</span>
${field.is_required ? '<span class="text-red-500">필수</span>' : '<span class="text-gray-400">선택</span>'}
</div>
</div>
`).join('');
} else {
document.getElementById('template-fields-section').classList.add('hidden');
}
}
// 폼 기본값으로 리셋
function resetFormToDefaults() {
document.getElementById('board_code').value = '';
document.getElementById('board_name').value = '';
document.getElementById('board_type').value = '';
document.getElementById('description').value = '';
document.getElementById('editor_type').value = 'wysiwyg';
document.getElementById('allow_files').checked = true;
document.getElementById('max_file_count').value = 5;
document.getElementById('max_file_size').value = 20480;
document.getElementById('template-fields-section').classList.add('hidden');
}
// 스텝 이동
function goToStep(step) {
currentStep = step;
// 모든 스텝 숨기기
document.getElementById('step1').classList.add('hidden');
document.getElementById('step2').classList.add('hidden');
document.getElementById('step3').classList.add('hidden');
// 현재 스텝 표시
document.getElementById(`step${step}`).classList.remove('hidden');
// 인디케이터 업데이트
updateStepIndicators(step);
// 스텝별 추가 처리
if (step === 2) {
// 템플릿 표시
document.getElementById('system-templates').classList.toggle('hidden', selectedScope !== 'system');
document.getElementById('tenant-templates').classList.toggle('hidden', selectedScope !== 'tenant');
}
if (step === 3) {
// 선택 정보 라벨 업데이트
const scopeLabel = selectedScope === 'system' ? '시스템 게시판' : '테넌트 게시판';
document.getElementById('selected-scope-label').textContent = scopeLabel;
if (selectedScope === 'tenant' && selectedTenantId) {
const tenant = tenants.find(t => t.id == selectedTenantId);
document.getElementById('selected-tenant-label').textContent = tenant ? `(${tenant.company_name})` : '';
} else {
document.getElementById('selected-tenant-label').textContent = '';
}
const templateLabel = selectedTemplateKey
? templates[selectedTemplateType]?.[selectedTemplateKey]?.name || selectedTemplateKey
: '빈 템플릿';
document.getElementById('selected-template-label').textContent = templateLabel;
}
}
// 스텝 인디케이터 업데이트
function updateStepIndicators(currentStep) {
for (let i = 1; i <= 3; i++) {
const indicator = document.getElementById(`step${i}-indicator`);
const circle = indicator.querySelector('div');
const text = indicator.querySelector('span');
const line = document.getElementById(`step-line-${i-1}`);
if (i < currentStep) {
// 완료된 스텝
circle.className = 'w-8 h-8 rounded-full bg-green-600 text-white flex items-center justify-center text-sm font-medium';
text.className = 'ml-2 text-sm font-medium text-green-600';
if (line) line.className = 'w-16 h-0.5 bg-green-600';
} else if (i === currentStep) {
// 현재 스텝
circle.className = 'w-8 h-8 rounded-full bg-blue-600 text-white flex items-center justify-center text-sm font-medium';
text.className = 'ml-2 text-sm font-medium text-blue-600';
if (line) line.className = 'w-16 h-0.5 bg-blue-600';
} else {
// 미완료 스텝
circle.className = 'w-8 h-8 rounded-full bg-gray-300 text-gray-600 flex items-center justify-center text-sm font-medium';
text.className = 'ml-2 text-sm font-medium text-gray-500';
if (line) line.className = 'w-16 h-0.5 bg-gray-300';
}
}
}
// 폼 제출
document.getElementById('boardForm').addEventListener('submit', async function(e) {
e.preventDefault();
const form = e.target;
const formData = new FormData(form);
const errorDiv = document.getElementById('errorMessage');
// 체크박스 처리
formData.set('allow_files', form.allow_files.checked ? '1' : '0');
formData.set('is_active', form.is_active.checked ? '1' : '0');
const payload = {
board_code: form.board_code.value,
name: form.name.value,
board_type: form.board_type.value || null,
description: form.description.value || null,
editor_type: form.editor_type.value,
allow_files: form.allow_files.checked,
max_file_count: parseInt(form.max_file_count.value),
max_file_size: parseInt(form.max_file_size.value),
is_active: form.is_active.checked,
template_type: selectedTemplateType,
template_key: selectedTemplateKey,
tenant_id: selectedScope === 'tenant' ? selectedTenantId : null
};
try {
const response = await fetch('/api/admin/boards', {
const response = await fetch('/api/admin/boards/create-from-template', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': '{{ csrf_token() }}',
'Accept': 'application/json'
},
body: JSON.stringify(Object.fromEntries(formData))
body: JSON.stringify(payload)
});
const data = await response.json();
if (response.ok && data.success) {
window.location.href = '{{ route('boards.index') }}';
createdBoardId = data.data.id;
showSuccessModal(data.data);
} else {
errorDiv.textContent = data.message || '게시판 생성에 실패했습니다.';
errorDiv.classList.remove('hidden');
@@ -178,5 +620,26 @@ class="px-6 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition"
errorDiv.classList.remove('hidden');
}
});
// 성공 모달 표시
function showSuccessModal(board) {
document.getElementById('success-board-name').textContent = `"${board.name}" 게시판`;
document.getElementById('success-board-code').textContent = board.board_code;
document.querySelectorAll('.board-code-placeholder').forEach(el => {
el.textContent = board.board_code;
});
document.getElementById('successModal').classList.remove('hidden');
document.getElementById('successModal').classList.add('flex');
}
// 성공 모달 닫기
function closeSuccessModal() {
document.getElementById('successModal').classList.add('hidden');
document.getElementById('successModal').classList.remove('flex');
// 커스텀 필드 추가를 위해 편집 페이지로 이동
if (createdBoardId) {
window.location.href = `/boards/${createdBoardId}/edit`;
}
}
</script>
@endpush

View File

@@ -146,6 +146,11 @@
Route::prefix('boards')->name('boards.')->group(function () {
// 고정 경로는 먼저 정의
Route::get('/stats', [BoardController::class, 'stats'])->name('stats');
Route::get('/templates', [BoardController::class, 'templates'])->name('templates');
Route::get('/templates/{type}/{key}', [BoardController::class, 'templateDetail'])->name('templateDetail');
Route::get('/tenants', [BoardController::class, 'tenants'])->name('tenants');
Route::post('/check-tenant-code', [BoardController::class, 'checkTenantCode'])->name('checkTenantCode');
Route::post('/create-from-template', [BoardController::class, 'storeFromTemplate'])->name('storeFromTemplate');
// 기본 CRUD
Route::get('/', [BoardController::class, 'index'])->name('index');