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
팝업 내용
", 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');