diff --git a/CURRENT_WORKS.md b/CURRENT_WORKS.md index 02d524d..36599e0 100644 --- a/CURRENT_WORKS.md +++ b/CURRENT_WORKS.md @@ -1846,4 +1846,64 @@ ### 주요 작업 - 계층 구조 및 필드 관리 테스트 - Validation 로직 개선 ---- \ No newline at end of file +--- +## 2025-12-19 (목) - Phase 6.2 팝업관리 API 구현 + +### 주요 작업 +- Phase 6.2 팝업관리 기능 구현 완료 +- PROJECT_DEVELOPMENT_POLICY.md 정책 준수 (string 타입, options JSON 가변 컬럼) + +### 추가된 파일 + +**마이그레이션:** +- `database/migrations/2025_12_19_170001_create_popups_table.php` + +**모델:** +- `app/Models/Popups/Popup.php` + - BelongsToTenant, SoftDeletes 적용 + - target_type: all(전사), department(부서) + - status: active(사용), inactive(사용안함) + - 스코프: active(), status(), targetType(), forUser() + - 관계: department(), creator(), updater() + +**서비스:** +- `app/Services/PopupService.php` + - index(): 관리자용 목록 (페이지네이션) + - getActivePopups(): 사용자용 활성 팝업 + - show(): 상세 조회 + - store(): 등록 + - update(): 수정 + - destroy(): 삭제 (Soft Delete) + +**FormRequest:** +- `app/Http/Requests/V1/Popup/StorePopupRequest.php` +- `app/Http/Requests/V1/Popup/UpdatePopupRequest.php` + +**컨트롤러:** +- `app/Http/Controllers/Api/V1/PopupController.php` + +**Swagger:** +- `app/Swagger/v1/PopupApi.php` + +### 수정된 파일 +- `routes/api.php`: Popup 라우트 추가 (6개 엔드포인트) + +### API 엔드포인트 (6개) +| Method | Path | Description | +|--------|------|-------------| +| GET | /api/v1/popups | 팝업 목록 (관리자용) | +| POST | /api/v1/popups | 팝업 등록 | +| GET | /api/v1/popups/active | 활성 팝업 (사용자용) | +| GET | /api/v1/popups/{id} | 팝업 상세 | +| PUT | /api/v1/popups/{id} | 팝업 수정 | +| DELETE | /api/v1/popups/{id} | 팝업 삭제 | + +### 정책 준수 사항 +- ✅ 기존 테이블 확인 후 신규 생성 +- ✅ string 타입 사용 (enum 대신) +- ✅ options JSON 가변 컬럼 +- ✅ BelongsToTenant, SoftDeletes 적용 +- ✅ Service-First 아키텍처 +- ✅ FormRequest 검증 + +--- diff --git a/LOGICAL_RELATIONSHIPS.md b/LOGICAL_RELATIONSHIPS.md index 8307bd9..94d66fb 100644 --- a/LOGICAL_RELATIONSHIPS.md +++ b/LOGICAL_RELATIONSHIPS.md @@ -1,6 +1,6 @@ # 논리적 데이터베이스 관계 문서 -> **자동 생성**: 2025-12-19 15:55:41 +> **자동 생성**: 2025-12-19 16:12:19 > **소스**: Eloquent 모델 관계 분석 ## 📊 모델별 관계 현황 @@ -320,6 +320,13 @@ ### role_menu_permissions - **role()**: belongsTo → `roles` - **menu()**: belongsTo → `menus` +### popups +**모델**: `App\Models\Popups\Popup` + +- **department()**: belongsTo → `departments` +- **creator()**: belongsTo → `users` +- **updater()**: belongsTo → `users` + ### prices **모델**: `App\Models\Products\Price` diff --git a/app/Http/Controllers/Api/V1/PopupController.php b/app/Http/Controllers/Api/V1/PopupController.php new file mode 100644 index 0000000..810f7c2 --- /dev/null +++ b/app/Http/Controllers/Api/V1/PopupController.php @@ -0,0 +1,89 @@ +only([ + 'target_type', + 'status', + 'search', + 'sort_by', + 'sort_dir', + 'per_page', + 'page', + ]); + + $popups = $this->service->index($params); + + return ApiResponse::success($popups, __('message.fetched')); + } + + /** + * 활성 팝업 목록 (사용자용) + */ + public function active(Request $request) + { + $departmentId = $request->input('department_id'); + + $popups = $this->service->getActivePopups($departmentId); + + return ApiResponse::success($popups, __('message.fetched')); + } + + /** + * 팝업 등록 + */ + public function store(StorePopupRequest $request) + { + $popup = $this->service->store($request->validated()); + + return ApiResponse::success($popup, __('message.created'), [], 201); + } + + /** + * 팝업 상세 + */ + public function show(int $id) + { + $popup = $this->service->show($id); + + return ApiResponse::success($popup, __('message.fetched')); + } + + /** + * 팝업 수정 + */ + public function update(int $id, UpdatePopupRequest $request) + { + $popup = $this->service->update($id, $request->validated()); + + return ApiResponse::success($popup, __('message.updated')); + } + + /** + * 팝업 삭제 + */ + public function destroy(int $id) + { + $this->service->destroy($id); + + return ApiResponse::success(null, __('message.deleted')); + } +} diff --git a/app/Http/Requests/V1/Popup/StorePopupRequest.php b/app/Http/Requests/V1/Popup/StorePopupRequest.php new file mode 100644 index 0000000..dd8a0b1 --- /dev/null +++ b/app/Http/Requests/V1/Popup/StorePopupRequest.php @@ -0,0 +1,55 @@ +|string> + */ + public function rules(): array + { + return [ + 'target_type' => ['sometimes', 'string', Rule::in(array_keys(Popup::TARGET_TYPES))], + 'target_id' => ['nullable', 'integer', 'exists:departments,id'], + 'title' => ['required', 'string', 'max:200'], + 'content' => ['required', 'string'], + 'status' => ['sometimes', 'string', Rule::in(array_keys(Popup::STATUSES))], + 'started_at' => ['nullable', 'date'], + 'ended_at' => ['nullable', 'date', 'after_or_equal:started_at'], + 'options' => ['nullable', 'array'], + ]; + } + + /** + * Get custom messages for validator errors. + * + * @return array + */ + public function messages(): array + { + return [ + 'target_type.in' => __('validation.in', ['attribute' => '대상 유형']), + 'target_id.exists' => __('validation.exists', ['attribute' => '대상 부서']), + 'title.required' => __('validation.required', ['attribute' => '제목']), + 'title.max' => __('validation.max.string', ['attribute' => '제목', 'max' => 200]), + 'content.required' => __('validation.required', ['attribute' => '내용']), + 'status.in' => __('validation.in', ['attribute' => '상태']), + 'ended_at.after_or_equal' => __('validation.after_or_equal', ['attribute' => '종료일', 'date' => '시작일']), + ]; + } +} diff --git a/app/Http/Requests/V1/Popup/UpdatePopupRequest.php b/app/Http/Requests/V1/Popup/UpdatePopupRequest.php new file mode 100644 index 0000000..c93fc98 --- /dev/null +++ b/app/Http/Requests/V1/Popup/UpdatePopupRequest.php @@ -0,0 +1,53 @@ +|string> + */ + public function rules(): array + { + return [ + 'target_type' => ['sometimes', 'string', Rule::in(array_keys(Popup::TARGET_TYPES))], + 'target_id' => ['nullable', 'integer', 'exists:departments,id'], + 'title' => ['sometimes', 'string', 'max:200'], + 'content' => ['sometimes', 'string'], + 'status' => ['sometimes', 'string', Rule::in(array_keys(Popup::STATUSES))], + 'started_at' => ['nullable', 'date'], + 'ended_at' => ['nullable', 'date', 'after_or_equal:started_at'], + 'options' => ['nullable', 'array'], + ]; + } + + /** + * Get custom messages for validator errors. + * + * @return array + */ + public function messages(): array + { + return [ + 'target_type.in' => __('validation.in', ['attribute' => '대상 유형']), + 'target_id.exists' => __('validation.exists', ['attribute' => '대상 부서']), + 'title.max' => __('validation.max.string', ['attribute' => '제목', 'max' => 200]), + 'status.in' => __('validation.in', ['attribute' => '상태']), + 'ended_at.after_or_equal' => __('validation.after_or_equal', ['attribute' => '종료일', 'date' => '시작일']), + ]; + } +} diff --git a/app/Models/Popups/Popup.php b/app/Models/Popups/Popup.php new file mode 100644 index 0000000..c3a427e --- /dev/null +++ b/app/Models/Popups/Popup.php @@ -0,0 +1,189 @@ + 'integer', + 'options' => 'array', + 'started_at' => 'datetime', + 'ended_at' => 'datetime', + ]; + + /** + * 대상 유형 상수 + */ + public const TARGET_ALL = 'all'; + + public const TARGET_DEPARTMENT = 'department'; + + /** + * 대상 유형 목록 + */ + public const TARGET_TYPES = [ + self::TARGET_ALL => '전사', + self::TARGET_DEPARTMENT => '부서', + ]; + + /** + * 상태 상수 + */ + public const STATUS_ACTIVE = 'active'; + + public const STATUS_INACTIVE = 'inactive'; + + /** + * 상태 목록 + */ + public const STATUSES = [ + self::STATUS_ACTIVE => '사용', + self::STATUS_INACTIVE => '사용안함', + ]; + + /** + * 부서 관계 (target_type='department' 일 때) + */ + public function department(): BelongsTo + { + return $this->belongsTo(Department::class, 'target_id'); + } + + /** + * 작성자 관계 + */ + public function creator(): BelongsTo + { + return $this->belongsTo(User::class, 'created_by'); + } + + /** + * 수정자 관계 + */ + public function updater(): BelongsTo + { + return $this->belongsTo(User::class, 'updated_by'); + } + + /** + * 대상 유형 라벨 속성 + */ + public function getTargetTypeLabelAttribute(): string + { + return self::TARGET_TYPES[$this->target_type] ?? $this->target_type; + } + + /** + * 상태 라벨 속성 + */ + public function getStatusLabelAttribute(): string + { + return self::STATUSES[$this->status] ?? $this->status; + } + + /** + * 대상명 속성 (전사 또는 부서명) + */ + public function getTargetNameAttribute(): string + { + if ($this->target_type === self::TARGET_ALL) { + return '전사'; + } + + return $this->department?->name ?? '-'; + } + + /** + * 활성 스코프 (상태=active AND 기간 내) + */ + public function scopeActive(Builder $query): Builder + { + $now = now(); + + return $query->where('status', self::STATUS_ACTIVE) + ->where(function ($q) use ($now) { + $q->whereNull('started_at') + ->orWhere('started_at', '<=', $now); + }) + ->where(function ($q) use ($now) { + $q->whereNull('ended_at') + ->orWhere('ended_at', '>=', $now); + }); + } + + /** + * 상태별 스코프 + */ + public function scopeStatus(Builder $query, string $status): Builder + { + return $query->where('status', $status); + } + + /** + * 대상 유형별 스코프 + */ + public function scopeTargetType(Builder $query, string $targetType): Builder + { + return $query->where('target_type', $targetType); + } + + /** + * 사용자 대상 팝업 스코프 (전사 또는 사용자 부서) + */ + public function scopeForUser(Builder $query, ?int $departmentId = null): Builder + { + return $query->where(function ($q) use ($departmentId) { + $q->where('target_type', self::TARGET_ALL); + + if ($departmentId) { + $q->orWhere(function ($q) use ($departmentId) { + $q->where('target_type', self::TARGET_DEPARTMENT) + ->where('target_id', $departmentId); + }); + } + }); + } + + /** + * options 헬퍼 메서드 + */ + public function getOption(string $key, mixed $default = null): mixed + { + return data_get($this->options, $key, $default); + } + + /** + * options 설정 헬퍼 메서드 + */ + public function setOption(string $key, mixed $value): void + { + $options = $this->options ?? []; + data_set($options, $key, $value); + $this->options = $options; + } +} diff --git a/app/Services/PopupService.php b/app/Services/PopupService.php new file mode 100644 index 0000000..fbda350 --- /dev/null +++ b/app/Services/PopupService.php @@ -0,0 +1,163 @@ +tenantId(); + + $query = Popup::query() + ->where('tenant_id', $tenantId) + ->with(['creator:id,name', 'department:id,name']); + + // 대상 유형 필터 + if (! empty($params['target_type'])) { + $query->where('target_type', $params['target_type']); + } + + // 상태 필터 + if (! empty($params['status'])) { + $query->where('status', $params['status']); + } + + // 검색어 필터 + if (! empty($params['search'])) { + $search = $params['search']; + $query->where(function ($q) use ($search) { + $q->where('title', 'like', "%{$search}%") + ->orWhere('content', 'like', "%{$search}%"); + }); + } + + // 정렬 + $sortBy = $params['sort_by'] ?? 'created_at'; + $sortDir = $params['sort_dir'] ?? 'desc'; + $query->orderBy($sortBy, $sortDir); + + // 페이지네이션 + $perPage = $params['per_page'] ?? 20; + + return $query->paginate($perPage); + } + + /** + * 활성 팝업 목록 조회 (사용자용 - 로그인 후 노출) + */ + public function getActivePopups(?int $departmentId = null): Collection + { + $tenantId = $this->tenantId(); + + return Popup::query() + ->where('tenant_id', $tenantId) + ->active() + ->forUser($departmentId) + ->orderBy('created_at', 'desc') + ->get(); + } + + /** + * 팝업 상세 조회 + */ + public function show(int $id): Popup + { + $tenantId = $this->tenantId(); + + return Popup::query() + ->where('tenant_id', $tenantId) + ->with(['creator:id,name', 'department:id,name']) + ->findOrFail($id); + } + + /** + * 팝업 등록 + */ + public function store(array $data): Popup + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + $popup = new Popup; + $popup->tenant_id = $tenantId; + $popup->target_type = $data['target_type'] ?? Popup::TARGET_ALL; + $popup->target_id = $data['target_id'] ?? null; + $popup->title = $data['title']; + $popup->content = $data['content']; + $popup->status = $data['status'] ?? Popup::STATUS_INACTIVE; + $popup->started_at = $data['started_at'] ?? null; + $popup->ended_at = $data['ended_at'] ?? null; + $popup->options = $data['options'] ?? null; + $popup->created_by = $userId; + $popup->save(); + + return $popup->load(['creator:id,name', 'department:id,name']); + } + + /** + * 팝업 수정 + */ + public function update(int $id, array $data): Popup + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + $popup = Popup::query() + ->where('tenant_id', $tenantId) + ->findOrFail($id); + + if (isset($data['target_type'])) { + $popup->target_type = $data['target_type']; + } + if (array_key_exists('target_id', $data)) { + $popup->target_id = $data['target_id']; + } + if (isset($data['title'])) { + $popup->title = $data['title']; + } + if (isset($data['content'])) { + $popup->content = $data['content']; + } + if (isset($data['status'])) { + $popup->status = $data['status']; + } + if (array_key_exists('started_at', $data)) { + $popup->started_at = $data['started_at']; + } + if (array_key_exists('ended_at', $data)) { + $popup->ended_at = $data['ended_at']; + } + if (array_key_exists('options', $data)) { + $popup->options = $data['options']; + } + + $popup->updated_by = $userId; + $popup->save(); + + return $popup->load(['creator:id,name', 'department:id,name']); + } + + /** + * 팝업 삭제 + */ + public function destroy(int $id): void + { + $tenantId = $this->tenantId(); + $userId = $this->apiUserId(); + + $popup = Popup::query() + ->where('tenant_id', $tenantId) + ->findOrFail($id); + + $popup->deleted_by = $userId; + $popup->save(); + $popup->delete(); + } +} diff --git a/app/Swagger/v1/PopupApi.php b/app/Swagger/v1/PopupApi.php new file mode 100644 index 0000000..665256c --- /dev/null +++ b/app/Swagger/v1/PopupApi.php @@ -0,0 +1,246 @@ +팝업 내용입니다.

", description="내용 (HTML)"), + * @OA\Property(property="status", type="string", enum={"active", "inactive"}, example="active", description="상태"), + * @OA\Property(property="started_at", type="string", format="date-time", nullable=true, example="2025-01-01T00:00:00", description="노출 시작일"), + * @OA\Property(property="ended_at", type="string", format="date-time", nullable=true, example="2025-12-31T23:59:59", description="노출 종료일"), + * @OA\Property(property="options", type="object", nullable=true, description="확장 옵션"), + * @OA\Property(property="created_by", type="integer", nullable=true, example=1), + * @OA\Property(property="updated_by", type="integer", nullable=true), + * @OA\Property(property="created_at", type="string", format="date-time"), + * @OA\Property(property="updated_at", type="string", format="date-time"), + * @OA\Property( + * property="creator", + * type="object", + * nullable=true, + * @OA\Property(property="id", type="integer"), + * @OA\Property(property="name", type="string") + * ), + * @OA\Property( + * property="department", + * type="object", + * nullable=true, + * @OA\Property(property="id", type="integer"), + * @OA\Property(property="name", type="string") + * ) + * ) + * + * @OA\Schema( + * schema="PopupPagination", + * allOf={ + * @OA\Schema(ref="#/components/schemas/PaginationMeta"), + * @OA\Schema( + * + * @OA\Property( + * property="data", + * type="array", + * + * @OA\Items(ref="#/components/schemas/Popup") + * ) + * ) + * } + * ) + * + * @OA\Schema( + * schema="PopupCreateRequest", + * required={"title", "content"}, + * + * @OA\Property(property="target_type", type="string", enum={"all", "department"}, example="all", description="대상 유형 (기본: all)"), + * @OA\Property(property="target_id", type="integer", nullable=true, example=null, description="대상 부서 ID (target_type=department 시 필수)"), + * @OA\Property(property="title", type="string", example="새 공지사항", maxLength=200, description="제목"), + * @OA\Property(property="content", type="string", example="

팝업 내용

", description="내용 (HTML)"), + * @OA\Property(property="status", type="string", enum={"active", "inactive"}, example="inactive", description="상태 (기본: inactive)"), + * @OA\Property(property="started_at", type="string", format="date", nullable=true, example="2025-01-01", description="노출 시작일"), + * @OA\Property(property="ended_at", type="string", format="date", nullable=true, example="2025-12-31", description="노출 종료일"), + * @OA\Property(property="options", type="object", nullable=true, description="확장 옵션") + * ) + * + * @OA\Schema( + * schema="PopupUpdateRequest", + * + * @OA\Property(property="target_type", type="string", enum={"all", "department"}, example="department", description="대상 유형"), + * @OA\Property(property="target_id", type="integer", nullable=true, example=1, description="대상 부서 ID"), + * @OA\Property(property="title", type="string", example="수정된 공지사항", maxLength=200, description="제목"), + * @OA\Property(property="content", type="string", example="

수정된 내용

", description="내용 (HTML)"), + * @OA\Property(property="status", type="string", enum={"active", "inactive"}, example="active", description="상태"), + * @OA\Property(property="started_at", type="string", format="date", nullable=true, example="2025-01-01", description="노출 시작일"), + * @OA\Property(property="ended_at", type="string", format="date", nullable=true, example="2025-12-31", description="노출 종료일"), + * @OA\Property(property="options", type="object", nullable=true, description="확장 옵션") + * ) + */ +class PopupApi +{ + /** + * @OA\Get( + * path="/api/v1/popups", + * tags={"Popup"}, + * summary="팝업 목록 조회 (관리자용)", + * security={{"ApiKeyAuth": {}, "BearerAuth": {}}}, + * + * @OA\Parameter(name="target_type", in="query", @OA\Schema(type="string", enum={"all", "department"}), description="대상 유형"), + * @OA\Parameter(name="status", in="query", @OA\Schema(type="string", enum={"active", "inactive"}), description="상태"), + * @OA\Parameter(name="search", in="query", @OA\Schema(type="string"), description="검색어 (제목, 내용)"), + * @OA\Parameter(name="sort_by", in="query", @OA\Schema(type="string", default="created_at"), description="정렬 기준"), + * @OA\Parameter(name="sort_dir", in="query", @OA\Schema(type="string", enum={"asc", "desc"}, default="desc"), description="정렬 방향"), + * @OA\Parameter(name="per_page", in="query", @OA\Schema(type="integer", default=20), description="페이지당 항목 수"), + * @OA\Parameter(name="page", in="query", @OA\Schema(type="integer", default=1), description="페이지 번호"), + * + * @OA\Response( + * response=200, + * description="성공", + * + * @OA\JsonContent(ref="#/components/schemas/PopupPagination") + * ) + * ) + */ + public function index() {} + + /** + * @OA\Get( + * path="/api/v1/popups/active", + * tags={"Popup"}, + * summary="활성 팝업 목록 조회 (사용자용 - 로그인 후 노출)", + * description="현재 노출 중인 팝업 목록을 반환합니다. status=active이고 현재 시간이 started_at~ended_at 범위에 있는 팝업만 조회됩니다.", + * security={{"ApiKeyAuth": {}, "BearerAuth": {}}}, + * + * @OA\Parameter(name="department_id", in="query", @OA\Schema(type="integer"), description="사용자의 부서 ID (해당 부서 대상 팝업 포함)"), + * + * @OA\Response( + * response=200, + * description="성공", + * + * @OA\JsonContent( + * + * @OA\Property(property="success", type="boolean", example=true), + * @OA\Property(property="message", type="string"), + * @OA\Property( + * property="data", + * type="array", + * + * @OA\Items(ref="#/components/schemas/Popup") + * ) + * ) + * ) + * ) + */ + public function active() {} + + /** + * @OA\Post( + * path="/api/v1/popups", + * tags={"Popup"}, + * summary="팝업 등록", + * security={{"ApiKeyAuth": {}, "BearerAuth": {}}}, + * + * @OA\RequestBody( + * required=true, + * + * @OA\JsonContent(ref="#/components/schemas/PopupCreateRequest") + * ), + * + * @OA\Response( + * response=201, + * description="생성 성공", + * + * @OA\JsonContent( + * + * @OA\Property(property="success", type="boolean", example=true), + * @OA\Property(property="message", type="string"), + * @OA\Property(property="data", ref="#/components/schemas/Popup") + * ) + * ) + * ) + */ + public function store() {} + + /** + * @OA\Get( + * path="/api/v1/popups/{id}", + * tags={"Popup"}, + * summary="팝업 상세 조회", + * security={{"ApiKeyAuth": {}, "BearerAuth": {}}}, + * + * @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer")), + * + * @OA\Response( + * response=200, + * description="성공", + * + * @OA\JsonContent( + * + * @OA\Property(property="success", type="boolean", example=true), + * @OA\Property(property="message", type="string"), + * @OA\Property(property="data", ref="#/components/schemas/Popup") + * ) + * ) + * ) + */ + public function show() {} + + /** + * @OA\Put( + * path="/api/v1/popups/{id}", + * tags={"Popup"}, + * summary="팝업 수정", + * security={{"ApiKeyAuth": {}, "BearerAuth": {}}}, + * + * @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer")), + * + * @OA\RequestBody( + * required=true, + * + * @OA\JsonContent(ref="#/components/schemas/PopupUpdateRequest") + * ), + * + * @OA\Response( + * response=200, + * description="수정 성공", + * + * @OA\JsonContent( + * + * @OA\Property(property="success", type="boolean", example=true), + * @OA\Property(property="message", type="string"), + * @OA\Property(property="data", ref="#/components/schemas/Popup") + * ) + * ) + * ) + */ + public function update() {} + + /** + * @OA\Delete( + * path="/api/v1/popups/{id}", + * tags={"Popup"}, + * summary="팝업 삭제", + * security={{"ApiKeyAuth": {}, "BearerAuth": {}}}, + * + * @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer")), + * + * @OA\Response( + * response=200, + * description="삭제 성공", + * + * @OA\JsonContent( + * + * @OA\Property(property="success", type="boolean", example=true), + * @OA\Property(property="message", type="string"), + * @OA\Property(property="data", type="null") + * ) + * ) + * ) + */ + public function destroy() {} +} diff --git a/database/migrations/2025_12_19_170001_create_popups_table.php b/database/migrations/2025_12_19_170001_create_popups_table.php new file mode 100644 index 0000000..f1c68c1 --- /dev/null +++ b/database/migrations/2025_12_19_170001_create_popups_table.php @@ -0,0 +1,51 @@ +id(); + + // 🔴 필수 컬럼 (조인/인덱싱) + $table->foreignId('tenant_id')->constrained()->cascadeOnDelete(); + $table->string('target_type', 20)->default('all')->comment('대상 유형: all, department'); + $table->unsignedBigInteger('target_id')->nullable()->comment('대상 ID (부서 ID)'); + $table->string('title', 200)->comment('제목'); + $table->text('content')->comment('내용'); + $table->string('status', 20)->default('inactive')->index()->comment('상태: active, inactive'); + $table->dateTime('started_at')->nullable()->index()->comment('노출 시작일'); + $table->dateTime('ended_at')->nullable()->index()->comment('노출 종료일'); + + // 🟢 가변 컬럼 (JSON) + $table->json('options')->nullable()->comment('확장 옵션'); + + // 감사 컬럼 + $table->unsignedBigInteger('created_by')->nullable()->comment('작성자'); + $table->unsignedBigInteger('updated_by')->nullable()->comment('수정자'); + $table->unsignedBigInteger('deleted_by')->nullable()->comment('삭제자'); + + $table->timestamps(); + $table->softDeletes(); + + // 인덱스 + $table->index('target_type'); + $table->index(['tenant_id', 'status', 'started_at', 'ended_at']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('popups'); + } +}; diff --git a/routes/api.php b/routes/api.php index 7caf4e6..29bcdb1 100644 --- a/routes/api.php +++ b/routes/api.php @@ -59,6 +59,7 @@ use App\Http\Controllers\Api\V1\PayrollController; use App\Http\Controllers\Api\V1\PermissionController; use App\Http\Controllers\Api\V1\PlanController; +use App\Http\Controllers\Api\V1\PopupController; use App\Http\Controllers\Api\V1\PostController; use App\Http\Controllers\Api\V1\PricingController; use App\Http\Controllers\Api\V1\PurchaseController; @@ -533,6 +534,16 @@ Route::delete('/{id}/memos/{memoId}', [BadDebtController::class, 'removeMemo'])->whereNumber(['id', 'memoId'])->name('v1.bad-debts.memos.destroy'); }); + // Popup API (팝업관리) + Route::prefix('popups')->group(function () { + Route::get('', [PopupController::class, 'index'])->name('v1.popups.index'); + Route::post('', [PopupController::class, 'store'])->name('v1.popups.store'); + Route::get('/active', [PopupController::class, 'active'])->name('v1.popups.active'); + Route::get('/{id}', [PopupController::class, 'show'])->whereNumber('id')->name('v1.popups.show'); + Route::put('/{id}', [PopupController::class, 'update'])->whereNumber('id')->name('v1.popups.update'); + Route::delete('/{id}', [PopupController::class, 'destroy'])->whereNumber('id')->name('v1.popups.destroy'); + }); + // Report API (보고서) Route::prefix('reports')->group(function () { Route::get('/daily', [ReportController::class, 'daily'])->name('v1.reports.daily');