# GLOBEAU 최고관리자 DAM(디지털 자산 관리) 및 이미지 시스템 작업 리포트

## 1. 작업 개요 및 표준화 목표
- **도메인 대상**: 최고관리자 뷰티샵(`Shop`) 및 시술 프로그램(`Program`) 모듈
- **핵심 목표**:
  1. 기존 물리 파일 직접 업로드 방식에서 중앙 미디어 DAM(`Image` 모델, `ImageService`) 기반 다형성 피벗(`imageables`) M:N 매핑 구조로 전면 전환.
  2. 최고관리자/샵관리자 전역 미리보기 렌더링 시 규격화된 우선순위 포맷 자동 Fallback 엔진 적용 (`Thumb` > `WebP` > `AVIF` > `원본` > `null`).
  3. 도메인 간 충돌 방지를 위해 슬롯 키(Slot Key) 명칭 표준화.
  4. 다중 갤러리 슬롯의 물리적 상한선(최대 30장) 프론트엔드(SweetAlert2) 및 백엔드(FormRequest/Controller) 2중 방어선 구축.
  5. 블레이드 컴포넌트(`x-admin.image-single-slot`, `x-admin.image-multi-slot`)를 등록(`create`) 및 수정(`edit`) 화면에서 `:model="$entity ?? null"` 구조로 단일화.

---

## 2. 이미지 포맷 우선순위 엔진 설계 (`Image` 모델)
- **우선순위 규격**: `thumbnail_path` (썸네일) > `webp_path` (WebP) > `avif_path` (AVIF) > `path` (스토리지 원본) > `null`
- **파일 경로**: `app/Models/Image.php`

    <?php

    namespace App\Models;

    use Illuminate\Database\Eloquent\Factories\HasFactory;
    use Illuminate\Database\Eloquent\Model;
    use Illuminate\Database\Eloquent\Relations\BelongsTo;
    use Illuminate\Database\Eloquent\Relations\BelongsToMany;
    use Illuminate\Database\Eloquent\Relations\MorphToMany;
    use Illuminate\Database\Eloquent\Builder;
    use Illuminate\Support\Facades\DB;
    use Illuminate\Support\Facades\Storage;

    class Image extends Model
    {
        use HasFactory;

        protected $fillable = [
            'shop_id',
            'uploaded_by',
            'visibility',
            'disk',
            'path',
            'avif_path',
            'webp_path',
            'thumbnail_path',
            'filename',
            'original_name',
            'mime_type',
            'size',
            'width',
            'height',
            'hash',
            'is_temp',
            'is_protected',
            'seo_slug',
            'title',
            'alt_text',
            'description',
            'tags',
            'credit',
            'usage_count',
        ];

        protected $casts = [
            'is_temp'      => 'boolean',
            'is_protected' => 'boolean',
            'tags'         => 'array',
            'size'         => 'integer',
            'width'        => 'integer',
            'height'       => 'integer',
            'usage_count'  => 'integer',
        ];

        protected $appends = [
            'preview_url',
            'thumb_url',
            'webp_url',
            'avif_url',
            'original_url',
        ];

        // -------------------------------------------------------------------------
        // 1. 관계 설정 (Relationships)
        // -------------------------------------------------------------------------

        public function shop(): BelongsTo
        {
            return $this->belongsTo(Shop::class, 'shop_id');
        }

        public function uploader(): BelongsTo
        {
            return $this->belongsTo(User::class, 'uploaded_by');
        }

        public function folders(): BelongsToMany
        {
            return $this->belongsToMany(ImageFolder::class, 'image_folder_pivot', 'image_id', 'folder_id')
                ->withTimestamps();
        }

        public function imageables(): MorphToMany
        {
            return $this->morphToMany(Model::class, 'imageable', 'imageables')
                ->withPivot(['type', 'sort_order'])
                ->withTimestamps();
        }

        public function shops(): MorphToMany
        {
            return $this->morphedByMany(Shop::class, 'imageable', 'imageables')
                ->withPivot(['type', 'sort_order'])
                ->withTimestamps()
                ->distinct();
        }

        public function programs(): MorphToMany
        {
            return $this->morphedByMany(Program::class, 'imageable', 'imageables')
                ->withPivot(['type', 'sort_order'])
                ->withTimestamps()
                ->distinct();
        }

        // -------------------------------------------------------------------------
        // 2. 포맷별 개별 URL 접근자 (URL Accessors)
        // -------------------------------------------------------------------------

        public function getOriginalUrlAttribute(): ?string
        {
            if (empty($this->path)) return null;
            $disk = Storage::disk($this->disk ?? 'public');
            return $disk->exists($this->path) ? $disk->url($this->path) : null;
        }

        public function getThumbUrlAttribute(): ?string
        {
            if (empty($this->thumbnail_path)) return null;
            $disk = Storage::disk($this->disk ?? 'public');
            return $disk->exists($this->thumbnail_path) ? $disk->url($this->thumbnail_path) : null;
        }

        public function getWebpUrlAttribute(): ?string
        {
            if (empty($this->webp_path)) return null;
            $disk = Storage::disk($this->disk ?? 'public');
            return $disk->exists($this->webp_path) ? $disk->url($this->webp_path) : null;
        }

        public function getAvifUrlAttribute(): ?string
        {
            if (empty($this->avif_path)) return null;
            $disk = Storage::disk($this->disk ?? 'public');
            return $disk->exists($this->avif_path) ? $disk->url($this->avif_path) : null;
        }

        // -------------------------------------------------------------------------
        // 3. 전역 표준 미리보기 엔진 (Thumb > WebP > AVIF > 원본 > null)
        // -------------------------------------------------------------------------

        public function getPreviewUrlAttribute(): ?string
        {
            $disk = Storage::disk($this->disk ?? 'public');

            // 1순위: 초경량 썸네일
            if (!empty($this->thumbnail_path) && $disk->exists($this->thumbnail_path)) {
                return $disk->url($this->thumbnail_path);
            }

            // 2순위: 고화질 표준 WebP
            if (!empty($this->webp_path) && $disk->exists($this->webp_path)) {
                return $disk->url($this->webp_path);
            }

            // 3순위: 차세대 고압축 AVIF
            if (!empty($this->avif_path) && $disk->exists($this->avif_path)) {
                return $disk->url($this->avif_path);
            }

            // 4순위: 스토리지 원본 물리 파일 (Fallback)
            if (!empty($this->path) && $disk->exists($this->path)) {
                return $disk->url($this->path);
            }

            return null;
        }

        // -------------------------------------------------------------------------
        // 4. 비즈니스 상태 판별 헬퍼 (Accessors)
        // -------------------------------------------------------------------------

        public function getIsUsedAttribute(): bool
        {
            if (isset($this->attributes['imageables_count'])) {
                return (int)$this->attributes['imageables_count'] > 0;
            }

            return DB::table('imageables')->where('image_id', $this->id)->exists();
        }

        public function getIsOrphanAttribute(): bool
        {
            return !$this->is_used;
        }

        public function getCanDeleteAttribute(): bool
        {
            return !$this->is_used && !$this->is_protected;
        }

        public function getUsagesAttribute(): array
        {
            $rows = DB::table('imageables')
                ->where('image_id', $this->id)
                ->get();

            $usages = [];
            foreach ($rows as $row) {
                $entityName = '알 수 없음';
                $editUrl = null;

                if ($row->imageable_type === 'shop' || str_ends_with($row->imageable_type, 'Shop')) {
                    $targetShop = Shop::find($row->imageable_id);
                    $entityName = $targetShop ? "[뷰티샵] {$targetShop->name}" : "[삭제된 뷰티샵]";
                    $editUrl = $targetShop ? route('admin.shops.edit', $targetShop->id) : null;
                } elseif ($row->imageable_type === 'program' || str_ends_with($row->imageable_type, 'Program')) {
                    $targetProgram = Program::find($row->imageable_id);
                    $entityName = $targetProgram ? "[프로그램] {$targetProgram->name}" : "[삭제된 프로그램]";
                    $editUrl = $targetProgram ? route('admin.programs.edit', $targetProgram->id) : null;
                }

                $usages[] = [
                    'type'        => $row->imageable_type,
                    'id'          => $row->imageable_id,
                    'slot'        => $row->type,
                    'entity_name' => $entityName,
                    'edit_url'    => $editUrl,
                ];
            }

            return $usages;
        }

        // -------------------------------------------------------------------------
        // 5. 보안 및 조회 스코프 (Query Scopes)
        // -------------------------------------------------------------------------

        public function scopeOrphans(Builder $query): Builder
        {
            return $query->whereNotExists(function ($subQuery) {
                $subQuery->select(DB::raw(1))
                    ->from('imageables')
                    ->whereColumn('imageables.image_id', 'images.id');
            });
        }

        public function scopeAccessibleByShop(Builder $query, ?int $shopId): Builder
        {
            return $query->where(function (Builder $q) use ($shopId) {
                $q->where('visibility', 'public');

                if ($shopId) {
                    $q->orWhere(function (Builder $subQ) use ($shopId) {
                        $subQ->where('visibility', 'private')
                            ->where('shop_id', $shopId);
                    });
                }
            });
        }
    }

---

## 3. 공용 슬롯 블레이드 컴포넌트

### 3.1. 멀티 갤러리 슬롯 컴포넌트
- **파일 경로**: `resources/views/components/admin/image-multi-slot.blade.php`
- **특징**: 기존 원본 디자인(`p-1`, `bg-white rounded`, `card-body p-2 bg-light`) 100% 복원, 1:1 비율 유지 및 `preview_url` 엔진 내장.

    @props([
        'name' => 'marketing',
        'label' => '마케팅 갤러리 이미지 관리',
        'model' => null,
        'maxCount' => 30,
        'badgeText' => '최대 30장 등록 가능',
        'buttonText' => '미디어 보관함에서 선택/업로드'
    ])

    @php
        $slotKey = $name;
        $previewContainerId = "{$slotKey}-gallery-preview";
        $hiddenContainerId = "{$slotKey}-hidden-inputs";

        $existingImages = collect();
        if ($model && method_exists($model, 'getSlotImages')) {
            $existingImages = $model->getSlotImages($slotKey);
        }
    @endphp

    <div class="app-form-card" data-slot-name="{{ $slotKey }}" data-max-count="{{ $maxCount }}">
        <div class="app-card-header d-flex justify-content-between align-items-center">
            <span><i class="fa-solid fa-images me-1 text-primary"></i> {{ $label }}</span>
            <span class="badge bg-secondary">{{ $badgeText }}</span>
        </div>
        <div class="app-card-body">
            <div class="mb-2">
                <div class="d-flex align-items-center gap-2 mb-2">
                    <button type="button" class="btn btn-outline-dark btn-sm fw-bold" onclick="openMediaModal('{{ $slotKey }}', 'multiple')">
                        <i class="fa-solid fa-images me-1"></i> {{ $buttonText }}
                    </button>
                </div>

                <div id="{{ $previewContainerId }}" class="row g-2 mt-2 {{ $slotKey }}-gallery-preview">
                    @foreach($existingImages as $img)
                        @php
                            $imgSrc = $img->preview_url ?: 'https://placehold.co/150x150?text=No+Image';
                            $sortOrder = $img->pivot->sort_order ?? 0;
                        @endphp
                        <div class="col-6 col-md-3 col-xxl-2 text-center position-relative multi-preview-item {{ $slotKey }}-preview-col mb-2" data-id="{{ $img->id }}">
                            <div class="card border shadow-sm h-100 position-relative bg-white rounded overflow-hidden p-1">
                                <button type="button" class="position-absolute top-0 end-0 btn btn-dark btn-sm p-0 d-flex align-items-center justify-content-center shadow-sm" 
                                        style="width: 20px; height: 20px; border-radius: 50%; z-index: 5; margin: 5px; font-size: 0.65rem;"
                                        onclick="removeMultiMediaItem(this, {{ $img->id }}, '{{ $slotKey }}')" title="이미지 제거">
                                    <i class="fa-solid fa-xmark"></i>
                                </button>

                                <div class="card-img-wrapper position-relative d-flex align-items-center justify-content-center overflow-hidden" 
                                     style="aspect-ratio: 1 / 1; width: 100%; background-color: #f8f9fa;">
                                    <img src="{{ $imgSrc }}" 
                                         alt="{{ $img->original_name }}" 
                                         style="max-width: 100%; max-height: 100%; object-fit: contain; display: block;"
                                         loading="lazy"
                                         onerror="this.src='https://placehold.co/150x150?text=No+Image'">
                                </div>

                                <div class="card-body p-2 bg-light d-flex flex-column gap-1">
                                    <div class="input-group input-group-sm">
                                        <span class="input-group-text bg-white small px-1 text-muted" style="font-size: 0.75rem;">순서</span>
                                        <input type="number" name="image_sorts[{{ $img->id }}]" value="{{ $sortOrder }}" class="form-control text-center text-dark fw-bold px-1 py-0" min="0">
                                    </div>
                                    <small class="text-dark text-truncate d-block fw-semibold px-1" style="font-size: 0.7rem;" title="{{ $img->original_name }}">{{ $img->original_name }}</small>
                                </div>
                            </div>
                        </div>
                    @endforeach
                </div>

                <div id="{{ $hiddenContainerId }}">
                    @foreach($existingImages as $img)
                        <input type="hidden" name="{{ $slotKey }}_image_ids[]" value="{{ $img->id }}" data-id="{{ $img->id }}">
                    @endforeach
                </div>
            </div>
        </div>
    </div>

---

## 4. 자바스크립트 모달 제어 및 30장 상한선 검증 (`modal_media.js`)
- **파일 경로**: `public/js/modal_media.js`
- **핵심 동작**: 기존 등록 장수와 신규 선택 장수를 합산하여 30장 초과 시 즉시 SweetAlert2 팝업 차단.

    function confirmMediaSelection() {
        if (selectedMediaImagesMap.size === 0) {
            Swal.fire({
                icon: 'info',
                title: '선택된 이미지 없음',
                text: '적용할 이미지를 1개 이상 선택해 주세요.',
                confirmButtonColor: '#0d6efd',
                target: document.getElementById('globeauMediaModal') || 'body'
            });
            return;
        }

        const imagesArray = Array.from(selectedMediaImagesMap.values());

        if (selectedMediaCallback && typeof selectedMediaCallback === 'function') {
            selectedMediaCallback(imagesArray, currentMediaType);
            if (globeauMediaModal) globeauMediaModal.hide();
            return;
        }

        if (currentMediaMode === 'single') {
            const imgObj = imagesArray[0];
            if (!imgObj) return;

            const hiddenInput = document.getElementById(`${currentMediaType}_image_id`) 
                             || document.getElementById(`${currentMediaType}_id`) 
                             || document.querySelector(`input[name="${currentMediaType}_image_id"]`);

            const previewBox = document.getElementById(`${currentMediaType}-preview`) 
                            || document.querySelector(`.${currentMediaType}-preview`);

            if (hiddenInput) {
                hiddenInput.value = imgObj.id;
                hiddenInput.dispatchEvent(new Event('change'));
            }

            if (previewBox) {
                const previewUrl = imgObj.thumb_url || imgObj.webp_url || `/storage/${imgObj.path}`;
                previewBox.innerHTML = `<img src="${previewUrl}" alt="${imgObj.name || '미리보기'}" style="max-width: 100%; max-height: 100%; object-fit: contain; display: block;">`;
            }

            const removeBtn = document.getElementById(`btn-remove-${currentMediaType}`);
            if (removeBtn) {
                removeBtn.style.display = 'inline-block';
            }

        } else {
            const previewContainer = document.getElementById(`${currentMediaType}-gallery-preview`) 
                                  || document.querySelector(`.${currentMediaType}-gallery-preview`);
            let hiddenInputContainer = document.getElementById(`${currentMediaType}-hidden-inputs`) 
                                    || document.querySelector(`.${currentMediaType}-hidden-inputs`);

            if (previewContainer) {
                const parentCard = previewContainer.closest('.app-form-card');
                const maxLimit = parentCard && parentCard.dataset.maxCount ? parseInt(parentCard.dataset.maxCount, 10) : 30;

                const currentItemCount = previewContainer.querySelectorAll('.multi-preview-item').length;
                const newImagesToAdd = imagesArray.filter(img => {
                    return !previewContainer.querySelector(`[data-id="${img.id}"]`);
                });

                if (currentItemCount + newImagesToAdd.length > maxLimit) {
                    Swal.fire({
                        icon: 'warning',
                        title: '갤러리 등록 수량 초과',
                        html: `마케팅 갤러리 이미지는 최대 <strong>${maxLimit}장</strong>까지만 등록할 수 있습니다.<br>` +
                              `<span class="text-muted small">(현재 등록: ${currentItemCount}장 / 추가 시도: ${newImagesToAdd.length}장 / 총합: ${currentItemCount + newImagesToAdd.length}장)</span>`,
                        confirmButtonColor: '#0d6efd',
                        target: document.getElementById('globeauMediaModal') || 'body'
                    });
                    return;
                }

                if (!hiddenInputContainer) {
                    const form = previewContainer.closest('form') || document.body;
                    hiddenInputContainer = document.createElement('div');
                    hiddenInputContainer.id = `${currentMediaType}-hidden-inputs`;
                    form.appendChild(hiddenInputContainer);
                }

                newImagesToAdd.forEach(img => {
                    appendMultiPreviewCard(img, previewContainer, hiddenInputContainer, currentMediaType);
                });
            }
        }

        if (globeauMediaModal) globeauMediaModal.hide();
    }

    function appendMultiPreviewCard(img, container, hiddenContainer, type) {
        const exists = container.querySelector(`[data-id="${img.id}"]`) 
                    || (hiddenContainer && hiddenContainer.querySelector(`input[value="${img.id}"]`));
        if (exists) return;

        const imgName = img.name || img.original_name || '이미지';
        const previewUrl = img.thumb_url || img.webp_url || `/storage/${img.path}`;

        const col = document.createElement('div');
        col.className = `col-6 col-md-3 col-xxl-2 text-center position-relative multi-preview-item ${type}-preview-col mb-2`;
        col.dataset.id = img.id;
        col.innerHTML = `
            <div class="card border shadow-sm h-100 position-relative bg-white rounded overflow-hidden p-1">
                <button type="button" class="position-absolute top-0 end-0 btn btn-dark btn-sm p-0 d-flex align-items-center justify-content-center shadow-sm" 
                        style="width: 20px; height: 20px; border-radius: 50%; z-index: 5; margin: 5px; font-size: 0.65rem;"
                        onclick="removeMultiMediaItem(this, ${img.id}, '${type}')" title="이미지 제거">
                    <i class="fa-solid fa-xmark"></i>
                </button>
                <div class="card-img-wrapper position-relative d-flex align-items-center justify-content-center overflow-hidden" 
                     style="aspect-ratio: 1 / 1; width: 100%; background-color: #f8f9fa;">
                    <img src="${previewUrl}" alt="${imgName}" style="max-width: 100%; max-height: 100%; object-fit: contain; display: block;" loading="lazy">
                </div>
                <div class="card-body p-2 bg-light d-flex flex-column gap-1">
                    <div class="input-group input-group-sm">
                        <span class="input-group-text bg-white small px-1 text-muted" style="font-size: 0.75rem;">순서</span>
                        <input type="number" name="image_sorts[${img.id}]" value="0" class="form-control text-center text-dark fw-bold px-1 py-0" min="0">
                    </div>
                    <small class="text-dark text-truncate d-block fw-semibold px-1" style="font-size: 0.7rem;" title="${imgName}">${imgName}</small>
                </div>
            </div>
        `;

        container.appendChild(col);

        if (hiddenContainer) {
            const hiddenInput = document.createElement('input');
            hiddenInput.type = 'hidden';
            hiddenInput.name = `${type}_image_ids[]`;
            hiddenInput.value = img.id;
            hiddenInput.dataset.id = img.id;
            hiddenContainer.appendChild(hiddenInput);
        }
    }

---

## 5. 뷰티샵(`Shop`) 모듈 전체 구성

### 5.1. 뷰티샵 컨트롤러 (`app/Http/Controllers/Admin/ShopController.php`)
- **슬롯 규격**: 싱글(`logo`, `banner`, `og_image`), 멀티(`marketing` - max:30)

    <?php

    namespace App\Http\Controllers\Admin;

    use App\Http\Controllers\Controller;
    use App\Models\Shop;
    use App\Models\User;
    use App\Models\Center;
    use App\Services\ImageService;
    use App\Services\NotificationService;
    use Illuminate\Http\Request;
    use Illuminate\Support\Facades\DB;

    class ShopController extends Controller
    {
        protected ImageService $imageService;

        public function __construct(ImageService $imageService)
        {
            $this->imageService = $imageService;
        }

        public function index(Request $request)
        {
            $query = Shop::with(['user', 'center', 'images'])
                ->where('status', '!=', 'pending');

            if ($request->filled('keyword')) {
                $keyword = $request->keyword;
                $query->where(function ($q) use ($keyword) {
                    $q->where('name', 'like', "%{$keyword}%")
                        ->orWhere('ceo_name', 'like', "%{$keyword}%")
                        ->orWhere('address1', 'like', "%{$keyword}%");
                });
            }

            if ($request->filled('status')) {
                $status = $request->status;
                if ($status === 'approved') {
                    $query->where('operating_status', 'active');
                } elseif ($status === 'pending') {
                    $query->where('operating_status', 'temp_closed');
                } elseif ($status === 'rejected') {
                    $query->whereIn('operating_status', ['suspended', 'closed']);
                }
            }

            $query->orderBy('is_active', 'desc')
                ->orderBy('is_pinned', 'desc')
                ->orderByRaw('CASE WHEN sort_order = 0 THEN 1 ELSE 0 END')
                ->orderBy('sort_order', 'asc')
                ->latest();

            $perPage = $request->input('per_page', 15);
            $shops = $query->paginate($perPage);

            if ($request->ajax()) {
                return view('admin.shops.partials.list_items', compact('shops'))->render();
            }
            return view('admin.shops.index', compact('shops'));
        }

        public function create()
        {
            $users = User::role(['shop', 'user'])->orderBy('name')->get();
            $centers = Center::orderBy('name')->get();

            return view('admin.shops.create', compact('users', 'centers'));
        }

        public function store(Request $request)
        {
            $request->merge([
                'user_id'         => trim($request->user_id) ?: null,
                'business_number' => trim($request->business_number) ?: null,
            ]);

            $validated = $request->validate([
                'name'             => 'required|string|max:255',
                'user_id'          => [
                    'nullable',
                    'exists:users,id',
                    function ($attribute, $value, $fail) {
                        if (!empty($value) && Shop::where('user_id', $value)->exists()) {
                            $fail('이미 다른 샵에 연동된 원장 계정입니다.');
                        }
                    },
                ],
                'center_id'        => 'nullable|exists:centers,id',
                'business_number'  => [
                    'nullable',
                    'string',
                    'max:50',
                    function ($attribute, $value, $fail) {
                        if (!empty($value) && Shop::where('business_number', $value)->exists()) {
                            $fail('이미 등록되어 있는 사업자 등록번호입니다.');
                        }
                    },
                ],
                'ceo_name'         => 'required|string|max:100',
                'ceo_phone'        => 'nullable|string|max:50',
                'ceo_email'        => 'nullable|email|max:255',
                'tel'              => 'nullable|string|max:50',
                'fax'              => 'nullable|string|max:50',
                'zipcode'          => 'nullable|string|max:20',
                'address1'         => 'nullable|string|max:255',
                'address2'         => 'nullable|string|max:255',
                'region_sido'      => 'required|string|max:30',
                'region_sigungu'   => 'required|string|max:30',
                'region_category'  => 'required|string|max:50',
                'latitude'         => 'nullable|numeric|between:-90,90',
                'longitude'        => 'nullable|numeric|between:-180,180',
                'location_coords'  => 'nullable|string|max:100',
                'bank_name'        => 'nullable|string|max:50',
                'bank_account'     => 'nullable|string|max:100',
                'bank_holder'      => 'nullable|string|max:100',

                'logo_image_id'    => 'nullable|integer|exists:images,id',
                'banner_image_id'  => 'nullable|integer|exists:images,id',
                'og_image_id'      => 'nullable|integer|exists:images,id',

                'naver_place_url'  => 'nullable|url|max:255',
                'youtube_id'       => 'nullable|string|max:100',
                'introduction'     => 'nullable|string|max:255',
                'description'      => 'nullable|string',
                'operating_status' => 'required|in:active,temp_closed,suspended,closed',
                'sort_order'       => 'required|integer|min:0',
                'admin_memo'       => 'nullable|string',
                'category_ids'     => 'nullable|array',
                'category_ids.*'   => 'exists:categories,id',

                'marketing_image_ids'   => 'nullable|array|max:30',
                'marketing_image_ids.*' => 'integer|exists:images,id',
                'image_sorts'           => 'nullable|array',
                'image_sorts.*'         => 'nullable|integer|min:0',
            ], [
                'marketing_image_ids.max' => '마케팅 갤러리 이미지는 최대 30장까지만 등록할 수 있습니다.',
            ]);

            $validated['is_verified']       = $request->has('is_verified') ? 1 : 0;
            $validated['is_phone_verified'] = $request->has('is_phone_verified') ? 1 : 0;
            $validated['is_pinned']         = $request->has('is_pinned') ? 1 : 0;
            $validated['status']            = 'approved';
            $validated['is_active']         = ($validated['operating_status'] === 'active');

            DB::beginTransaction();
            try {
                $shopData = collect($validated)->except([
                    'category_ids',
                    'marketing_image_ids',
                    'image_sorts',
                    'logo_image_id',
                    'banner_image_id',
                    'og_image_id',
                ])->toArray();

                $shop = Shop::create($shopData);

                if ($request->filled('category_ids')) {
                    $shop->categories()->sync($request->input('category_ids'));
                }

                $singleSlots = [
                    'logo_image_id'   => ['slot' => 'logo',     'column' => 'logo_path'],
                    'banner_image_id' => ['slot' => 'banner',   'column' => 'banner_path'],
                    'og_image_id'     => ['slot' => 'og_image', 'column' => 'og_image_path'],
                ];

                foreach ($singleSlots as $inputKey => $cfg) {
                    if ($request->filled($inputKey)) {
                        $this->imageService->syncSingle(
                            $shop,
                            $cfg['slot'],
                            (int)$request->input($inputKey),
                            $cfg['column']
                        );
                    }
                }

                $marketingIds = $request->input('marketing_image_ids', []);
                $imageSorts = $request->input('image_sorts', []);
                if (!empty($marketingIds)) {
                    $this->imageService->syncMulti(
                        $shop,
                        'marketing',
                        $marketingIds,
                        $imageSorts
                    );
                }

                if (!empty($shop->description)) {
                    $updatedContent = $this->imageService->syncEditorContent(
                        $shop->description,
                        $shop,
                        'description'
                    );
                    if ($updatedContent !== $shop->description) {
                        $shop->update(['description' => $updatedContent]);
                    }
                }

                DB::commit();
                return redirect()->route('admin.shops.index')->with('success', '신규 뷰티샵이 성공적으로 등록되었습니다.');

            } catch (\Exception $e) {
                DB::rollBack();
                return back()->withInput()->with('error', '뷰티샵 등록 중 오류가 발생했습니다: ' . $e->getMessage());
            }
        }

        public function edit(Shop $shop)
        {
            $shop->load(['categories.parent', 'images']);
            $users = User::role(['shop', 'user'])->orderBy('name')->get();
            $centers = Center::orderBy('name')->get();

            return view('admin.shops.edit', compact('shop', 'users', 'centers'));
        }

        public function update(Request $request, Shop $shop)
        {
            $request->merge([
                'user_id'         => trim($request->user_id) ?: null,
                'business_number' => trim($request->business_number) ?: null,
            ]);

            $validated = $request->validate([
                'name'             => 'required|string|max:255',
                'user_id'          => [
                    'nullable',
                    'exists:users,id',
                    function ($attribute, $value, $fail) use ($shop) {
                        if (!empty($value) && Shop::where('user_id', $value)->where('id', '!=', $shop->id)->exists()) {
                            $fail('이미 다른 샵에 연동된 원장 계정입니다.');
                        }
                    },
                ],
                'center_id'        => 'nullable|exists:centers,id',
                'business_number'  => [
                    'nullable',
                    'string',
                    'max:50',
                    function ($attribute, $value, $fail) use ($shop) {
                        if (!empty($value) && Shop::where('business_number', $value)->where('id', '!=', $shop->id)->exists()) {
                            $fail('이미 등록되어 있는 사업자 등록번호입니다.');
                        }
                    },
                ],
                'ceo_name'         => 'required|string|max:100',
                'ceo_phone'        => 'nullable|string|max:50',
                'ceo_email'        => 'nullable|email|max:255',
                'tel'              => 'nullable|string|max:50',
                'fax'              => 'nullable|string|max:50',
                'zipcode'          => 'nullable|string|max:20',
                'address1'         => 'nullable|string|max:255',
                'address2'         => 'nullable|string|max:255',
                'region_sido'      => 'required|string|max:30',
                'region_sigungu'   => 'required|string|max:30',
                'region_category'  => 'required|string|max:50',
                'latitude'         => 'nullable|numeric|between:-90,90',
                'longitude'        => 'nullable|numeric|between:-180,180',
                'location_coords'  => 'nullable|string|max:100',
                'bank_name'        => 'nullable|string|max:50',
                'bank_account'     => 'nullable|string|max:100',
                'bank_holder'      => 'nullable|string|max:100',

                'logo_image_id'    => 'nullable|integer|exists:images,id',
                'banner_image_id'  => 'nullable|integer|exists:images,id',
                'og_image_id'      => 'nullable|integer|exists:images,id',

                'naver_place_url'  => 'nullable|url|max:255',
                'youtube_id'       => 'nullable|string|max:100',
                'introduction'     => 'nullable|string|max:255',
                'description'      => 'nullable|string',
                'operating_status' => 'required|in:active,temp_closed,suspended,closed',
                'sort_order'       => 'required|integer|min:0',
                'admin_memo'       => 'nullable|string',
                'category_ids'     => 'nullable|array',
                'category_ids.*'   => 'exists:categories,id',

                'marketing_image_ids'   => 'nullable|array|max:30',
                'marketing_image_ids.*' => 'integer|exists:images,id',
                'image_sorts'           => 'nullable|array',
                'image_sorts.*'         => 'nullable|integer|min:0',
            ], [
                'marketing_image_ids.max' => '마케팅 갤러리 이미지는 최대 30장까지만 등록할 수 있습니다.',
            ]);

            $validated['is_verified']       = $request->has('is_verified') ? 1 : 0;
            $validated['is_phone_verified'] = $request->has('is_phone_verified') ? 1 : 0;
            $validated['is_pinned']         = $request->has('is_pinned') ? 1 : 0;

            DB::beginTransaction();
            try {
                $shopData = collect($validated)->except([
                    'category_ids',
                    'marketing_image_ids',
                    'image_sorts',
                    'logo_image_id',
                    'banner_image_id',
                    'og_image_id',
                ])->toArray();

                $shop->update($shopData);

                $shop->categories()->sync($request->input('category_ids', []));

                $singleSlots = [
                    'logo_image_id'   => ['slot' => 'logo',     'column' => 'logo_path'],
                    'banner_image_id' => ['slot' => 'banner',   'column' => 'banner_path'],
                    'og_image_id'     => ['slot' => 'og_image', 'column' => 'og_image_path'],
                ];

                foreach ($singleSlots as $inputKey => $cfg) {
                    if ($request->exists($inputKey)) {
                        $newId = $request->input($inputKey);
                        $this->imageService->syncSingle(
                            $shop,
                            $cfg['slot'],
                            !empty($newId) ? (int)$newId : null,
                            $cfg['column']
                        );
                    }
                }

                $this->imageService->syncMulti(
                    $shop,
                    'marketing',
                    $request->input('marketing_image_ids', []),
                    $request->input('image_sorts', [])
                );

                if ($request->has('description')) {
                    $updatedContent = $this->imageService->syncEditorContent(
                        $request->input('description'),
                        $shop,
                        'description'
                    );
                    $shop->update(['description' => $updatedContent]);
                }

                DB::commit();
                return redirect()->route('admin.shops.index')->with('success', '뷰티샵 정보가 성공적으로 수정되었습니다.');

            } catch (\Exception $e) {
                DB::rollBack();
                return back()->withInput()->with('error', '수정 처리 중 오류가 발생했습니다: ' . $e->getMessage());
            }
        }

        public function destroy(Shop $shop)
        {
            try {
                $shop->operating_status = 'closed';
                $shop->save();
                $shop->delete();

                return redirect()->route('admin.shops.index')->with('success', '뷰티샵이 폐점(삭제) 처리되었습니다.');
            } catch (\Exception $e) {
                return back()->with('error', '삭제 처리 중 오류가 발생했습니다: ' . $e->getMessage());
            }
        }

        public function updateInline(Request $request, Shop $shop)
        {
            $validated = $request->validate([
                'is_pinned'         => 'sometimes|boolean',
                'is_main_displayed' => 'sometimes|boolean',
                'sort_order'        => 'sometimes|integer|min:0',
                'operating_status'  => 'sometimes|in:active,temp_closed,suspended,closed',
            ]);

            $targetActive = isset($validated['operating_status'])
                ? ($validated['operating_status'] === 'active')
                : $shop->is_active;

            if (!$targetActive) {
                if (isset($validated['is_pinned']) && $validated['is_pinned'] == 1) {
                    return response()->json([
                        'success' => false,
                        'message' => '영업 중이 아닌 뷰티샵은 상단고정을 할 수 없습니다.'
                    ], 422);
                }
                if (isset($validated['is_main_displayed']) && $validated['is_main_displayed'] == 1) {
                    return response()->json([
                        'success' => false,
                        'message' => '영업 중이 아닌 뷰티샵은 메인노출을 할 수 없습니다.'
                    ], 422);
                }
            }

            if (isset($validated['is_pinned']) && $validated['is_pinned'] == 1) {
                if (!$shop->is_pinned) {
                    $pinnedCount = Shop::where('is_pinned', true)->count();
                    if ($pinnedCount >= 3) {
                        return response()->json([
                            'success' => false,
                            'message' => '상단 노출은 최대 3개까지만 가능합니다.'
                        ], 422);
                    }
                }
            }

            $shop->update($validated);

            return response()->json([
                'success' => true,
                'message' => '성공적으로 반영되었습니다.'
            ]);
        }

        public function pendingList(Request $request)
        {
            $query = Shop::with(['user', 'center'])->whereIn('status', ['pending', 'rejected']);

            if ($request->filled('keyword')) {
                $keyword = $request->keyword;
                $query->where(function ($q) use ($keyword) {
                    $q->where('name', 'like', "%{$keyword}%")
                        ->orWhere('ceo_name', 'like', "%{$keyword}%")
                        ->orWhere('address1', 'like', "%{$keyword}%");
                });
            }

            $shops = $query->latest()->paginate(15);
            return view('admin.shops.pending', compact('shops'));
        }

        public function pendingShow($id)
        {
            $shop = Shop::with(['user', 'center'])->findOrFail($id);
            return view('admin.shops.pending_show', compact('shop'));
        }

        public function approve($id)
        {
            $shop = Shop::findOrFail($id);
            $user = $shop->user;

            if (!$user) {
                return back()->with('error', '해당 뷰티샵에 연동된 신청인(회원) 정보를 찾을 수 없습니다.');
            }

            if ($user->status !== 'active') {
                return back()->with('error', "신청인의 계정 상태가 정상(active)이 아니므로 입점을 승인할 수 없습니다. (현재 상태: {$user->status})");
            }

            $shop->update([
                'status'           => 'approved',
                'operating_status' => 'temp_closed',
                'is_active'        => true,
                'approved_at'      => now(),
                'rejected_at'      => null,
                'rejection_reason' => null,
            ]);

            if (!$user->hasRole('shop')) {
                $user->assignRole('shop');
            }

            $notificationService = app(NotificationService::class);
            $applicantPhone = $user->phone ?? null;

            if ($applicantPhone) {
                $notificationService->send(
                    templateCode: 'SHOP_APPROVE_SHOP',
                    receiver: $applicantPhone,
                    variables: [
                        'shop_name'      => $shop->name,
                        'applicant_name' => $user->name ?? $shop->ceo_name,
                    ],
                    userId: $user->id
                );
            }

            return back()->with('success', "뷰티샵 '{$shop->name}'의 입점이 승인되었습니다.");
        }

        public function reject(Request $request, $id)
        {
            $request->validate([
                'rejection_reason' => 'required|string|max:500'
            ], [
                'rejection_reason.required' => '반려 사유를 반드시 입력해주세요.'
            ]);

            $shop = Shop::findOrFail($id);

            $shop->update([
                'status'           => 'rejected',
                'operating_status' => 'suspended',
                'is_active'        => false,
                'rejected_at'      => now(),
                'rejection_reason' => $request->rejection_reason,
            ]);

            return back()->with('success', "뷰티샵 '{$shop->name}'의 입점이 반려되었습니다.");
        }

        public function searchAjax(Request $request)
        {
            $keyword = $request->input('keyword');
            $excludeIds = $request->input('exclude');

            $query = Shop::where('status', 'approved');

            if (!empty($excludeIds)) {
                $excludeArray = explode(',', $excludeIds);
                $query->whereNotIn('id', $excludeArray);
            }

            if (!empty($keyword)) {
                $query->where(function ($q) use ($keyword) {
                    $q->where('name', 'like', "%{$keyword}%")
                        ->orWhere('ceo_name', 'like', "%{$keyword}%")
                        ->orWhere('address1', 'like', "%{$keyword}%");
                });
            }

            $shops = $query->orderBy('name', 'asc')
                ->limit(30)
                ->get(['id', 'name', 'ceo_name', 'address1', 'created_at']);

            return response()->json(['shops' => $shops]);
        }
    }

### 5.2. 뷰티샵 블레이드 적용 예시
- **인덱스 리스트 (`resources/views/admin/shops/index.blade.php`)**:

    <td class="text-center py-3">
        @php
            $logoImage = method_exists($shop, 'getSlotImage') ?$shop->getSlotImage('logo') : null;
            $displayThumbUrl =$logoImage?->preview_url;

            if (!$displayThumbUrl && !empty($shop->logo_path)) {
                $displayThumbUrl = asset('storage/' .$shop->logo_path);
            }

            $defaultPlaceholder = 'https://placehold.co/120x120?text=No+Image';
        @endphp

        <div class="position-relative d-inline-block">
            <div class="d-flex align-items-center justify-content-center border rounded shadow-sm bg-light overflow-hidden" 
                 style="width: 120px; height: 120px;">
                <img src="{{ $displayThumbUrl ?: $defaultPlaceholder }}"
                     alt="{{ $shop->name ?? '이미지' }}" 
                     style="max-width: 100%; max-height: 100%; object-fit: contain; display: block;"
                     loading="lazy"
                     onerror="this.src='{{ $defaultPlaceholder }}'">
            </div>

            @if ($shop->is_pinned ?? false)
                <span class="position-absolute top-0 start-0 translate-middle badge bg-primary border border-white rounded-pill shadow-sm"
                      style="font-size: 0.65rem; z-index: 5; white-space: nowrap;">
                    상단고정
                </span>
            @endif
        </div>
    </td>

- **등록/수정 폼 공용 호출 (`resources/views/admin/shops/create.blade.php` & `edit.blade.php`)**:

    <x-admin.image-multi-slot 
        name="marketing" 
        label="마케팅 갤러리 이미지 관리" 
        :model="$shop ?? null" 
        :maxCount="30" 
        badgeText="최대 30장 등록 가능" />

---

## 6. 시술 프로그램(`Program`) 모듈 전체 구성

### 6.1. 슬롯 명칭 표준화
- `program_logo`: 1:1 대표 로고/썸네일 (`logo_path` 컬럼 동기화)
- `program_banner`: 16:9 와이드 커버/배너 (`banner_path` 컬럼 동기화)
- `program_og_image`: SNS/카카오톡 링크 공유용 OG 이미지 (`og_image_path` 컬럼 동기화)
- `program_marketing`: 시술 전/후 및 임상 갤러리 (최대 30장 제한)

### 6.2. 등록 및 수정 폼 블레이드 공용 적용부

    <!-- 1. 프로그램 기본 이미지 관리 섹션 (싱글 슬롯 3종) -->
    <div class="app-form-card mb-4">
        <div class="app-card-header">
            <span><i class="fa-solid fa-image me-1 text-primary"></i> 프로그램 기본 이미지 관리</span>
        </div>
        <div class="app-card-body">
            <div class="row g-3">
                <div class="col-md-4">
                    <x-admin.image-single-slot 
                        name="program_logo" 
                        label="대표 로고/썸네일 (1:1)" 
                        :model="$program ?? null" 
                        width="80" 
                        height="80" 
                        ratioText="권장 비율: 1:1 정사각형 (목록 노출용)" />
                </div>

                <div class="col-md-4">
                    <x-admin.image-single-slot 
                        name="program_banner" 
                        label="상세 와이드 배너 (16:9)" 
                        :model="$program ?? null" 
                        width="140" 
                        height="80" 
                        ratioText="권장 비율: 16:9 가로형 (상세 상단 커버)" />
                </div>

                <div class="col-md-4">
                    <x-admin.image-single-slot 
                        name="program_og_image" 
                        label="소셜 공유 (OG) 이미지" 
                        :model="$program ?? null" 
                        width="140" 
                        height="80" 
                        ratioText="권장 비율: 1.91:1 (카카오톡/SNS 공유용)" />
                </div>
            </div>
        </div>
    </div>

    <!-- 2. 프로그램 마케팅/임상 갤러리 섹션 (멀티 슬롯, 최대 30장 제한) -->
    <x-admin.image-multi-slot 
        name="program_marketing" 
        label="프로그램 마케팅 갤러리 관리" 
        :model="$program ?? null" 
        :maxCount="30" 
        badgeText="최대 30장 등록 가능" 
        buttonText="미디어 보관함에서 선택/업로드" />

### 6.3. 프로그램 목록 테이블 (`resources/views/admin/programs/index.blade.php`)

    <td class="text-center py-3">
        @php
            $logoImage = method_exists($program, 'getSlotImage') ?$program->getSlotImage('program_logo') : null;
            $displayThumbUrl =$logoImage?->preview_url;

            if (!$displayThumbUrl && !empty($program->logo_path)) {
                $displayThumbUrl = asset('storage/' .$program->logo_path);
            }

            $defaultPlaceholder = 'https://placehold.co/120x120?text=No+Image';
        @endphp

        <div class="position-relative d-inline-block">
            <div class="d-flex align-items-center justify-content-center border rounded shadow-sm bg-light overflow-hidden" 
                 style="width: 120px; height: 120px;">
                <img src="{{ $displayThumbUrl ?: $defaultPlaceholder }}"
                     alt="{{ $program->name ?? '프로그램 이미지' }}" 
                     style="max-width: 100%; max-height: 100%; object-fit: contain; display: block;"
                     loading="lazy"
                     onerror="this.src='{{ $defaultPlaceholder }}'">
            </div>

            @if ($program->is_pinned ?? false)
                <span class="position-absolute top-0 start-0 translate-middle badge bg-primary border border-white rounded-pill shadow-sm"
                      style="font-size: 0.65rem; z-index: 5; white-space: nowrap;">
                    상단고정
                </span>
            @endif
        </div>
    </td>