From 8f1292f7c497f2f849966c6d8c7ed6ad12250fa4 Mon Sep 17 00:00:00 2001
From: hskwon
Date: Fri, 19 Dec 2025 16:14:04 +0900
Subject: [PATCH] =?UTF-8?q?feat:=20Phase=206.2=20=ED=8C=9D=EC=97=85?=
=?UTF-8?q?=EA=B4=80=EB=A6=AC=20API=20=EA=B5=AC=ED=98=84?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- popups 테이블 마이그레이션 생성
- Popup 모델 (BelongsToTenant, SoftDeletes)
- PopupService CRUD 구현
- FormRequest 검증 (Store/Update)
- PopupController 6개 엔드포인트
- Swagger 문서 (PopupApi.php)
- PROJECT_DEVELOPMENT_POLICY.md 정책 준수
---
CURRENT_WORKS.md | 62 ++++-
LOGICAL_RELATIONSHIPS.md | 9 +-
.../Controllers/Api/V1/PopupController.php | 89 +++++++
.../Requests/V1/Popup/StorePopupRequest.php | 55 ++++
.../Requests/V1/Popup/UpdatePopupRequest.php | 53 ++++
app/Models/Popups/Popup.php | 189 ++++++++++++++
app/Services/PopupService.php | 163 ++++++++++++
app/Swagger/v1/PopupApi.php | 246 ++++++++++++++++++
.../2025_12_19_170001_create_popups_table.php | 51 ++++
routes/api.php | 11 +
10 files changed, 926 insertions(+), 2 deletions(-)
create mode 100644 app/Http/Controllers/Api/V1/PopupController.php
create mode 100644 app/Http/Requests/V1/Popup/StorePopupRequest.php
create mode 100644 app/Http/Requests/V1/Popup/UpdatePopupRequest.php
create mode 100644 app/Models/Popups/Popup.php
create mode 100644 app/Services/PopupService.php
create mode 100644 app/Swagger/v1/PopupApi.php
create mode 100644 database/migrations/2025_12_19_170001_create_popups_table.php
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');