feat: Phase 6.2 팝업관리 API 구현

- popups 테이블 마이그레이션 생성
- Popup 모델 (BelongsToTenant, SoftDeletes)
- PopupService CRUD 구현
- FormRequest 검증 (Store/Update)
- PopupController 6개 엔드포인트
- Swagger 문서 (PopupApi.php)
- PROJECT_DEVELOPMENT_POLICY.md 정책 준수
This commit is contained in:
2025-12-19 16:14:04 +09:00
parent ac551d2c30
commit 8f1292f7c4
10 changed files with 926 additions and 2 deletions

View File

@@ -1846,4 +1846,64 @@ ### 주요 작업
- 계층 구조 및 필드 관리 테스트
- Validation 로직 개선
---
---
## 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 검증
---

View File

@@ -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`

View File

@@ -0,0 +1,89 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Helpers\ApiResponse;
use App\Http\Controllers\Controller;
use App\Http\Requests\V1\Popup\StorePopupRequest;
use App\Http\Requests\V1\Popup\UpdatePopupRequest;
use App\Services\PopupService;
use Illuminate\Http\Request;
class PopupController extends Controller
{
public function __construct(
private readonly PopupService $service
) {}
/**
* 팝업 목록 (관리자용)
*/
public function index(Request $request)
{
$params = $request->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'));
}
}

View File

@@ -0,0 +1,55 @@
<?php
namespace App\Http\Requests\V1\Popup;
use App\Models\Popups\Popup;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class StorePopupRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|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<string, string>
*/
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' => '시작일']),
];
}
}

View File

@@ -0,0 +1,53 @@
<?php
namespace App\Http\Requests\V1\Popup;
use App\Models\Popups\Popup;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class UpdatePopupRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|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<string, string>
*/
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' => '시작일']),
];
}
}

189
app/Models/Popups/Popup.php Normal file
View File

@@ -0,0 +1,189 @@
<?php
namespace App\Models\Popups;
use App\Models\Members\User;
use App\Models\Tenants\Department;
use App\Traits\BelongsToTenant;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes;
class Popup extends Model
{
use BelongsToTenant, SoftDeletes;
protected $fillable = [
'tenant_id',
'target_type',
'target_id',
'title',
'content',
'status',
'started_at',
'ended_at',
'options',
'created_by',
'updated_by',
'deleted_by',
];
protected $casts = [
'target_id' => '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;
}
}

View File

@@ -0,0 +1,163 @@
<?php
namespace App\Services;
use App\Models\Popups\Popup;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Support\Collection;
class PopupService extends Service
{
/**
* 팝업 목록 조회 (관리자용)
*/
public function index(array $params): LengthAwarePaginator
{
$tenantId = $this->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();
}
}

246
app/Swagger/v1/PopupApi.php Normal file
View File

@@ -0,0 +1,246 @@
<?php
namespace App\Swagger\v1;
/**
* @OA\Tag(name="Popup", description="팝업관리")
*
* @OA\Schema(
* schema="Popup",
*
* @OA\Property(property="id", type="integer", example=1),
* @OA\Property(property="tenant_id", type="integer", example=1),
* @OA\Property(property="target_type", type="string", enum={"all", "department"}, example="all", description="대상 유형"),
* @OA\Property(property="target_id", type="integer", nullable=true, example=null, description="대상 ID (부서 ID)"),
* @OA\Property(property="title", type="string", example="공지사항 팝업", description="제목"),
* @OA\Property(property="content", type="string", example="<p>팝업 내용입니다.</p>", 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="<p>팝업 내용</p>", 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="<p>수정된 내용</p>", 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() {}
}

View File

@@ -0,0 +1,51 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('popups', function (Blueprint $table) {
$table->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');
}
};

View File

@@ -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');