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:
@@ -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],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
290
config/board_templates.php
Normal 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' => [],
|
||||
],
|
||||
|
||||
],
|
||||
|
||||
];
|
||||
@@ -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">
|
||||
← 목록으로
|
||||
</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">
|
||||
← 이전 단계
|
||||
</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,48 +266,351 @@ 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">
|
||||
<a href="{{ route('boards.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">
|
||||
생성
|
||||
<div class="flex justify-between">
|
||||
<button type="button" onclick="goToStep(2)" class="px-4 py-2 text-gray-600 hover:text-gray-900">
|
||||
← 이전 단계
|
||||
</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">
|
||||
취소
|
||||
</a>
|
||||
<button type="submit"
|
||||
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
|
||||
@endpush
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user