관리자 상품관리 완료

웹사이트 상품리스트 상세보기 작업중
This commit is contained in:
sungro815 2026-02-20 18:11:03 +09:00
parent 6e8e8b5a57
commit b0545ab5b9
48 changed files with 6188 additions and 175 deletions

View File

@ -0,0 +1,118 @@
<?php
namespace App\Http\Controllers\Admin\Product;
use App\Services\Admin\Product\AdminCategoryService;
use Illuminate\Http\Request;
final class AdminCategoryController
{
public function __construct(
private readonly AdminCategoryService $service,
) {}
public function index(Request $request)
{
$tree = $this->service->getCategoryTree();
return view('admin.product.category.index', ['tree' => $tree]);
}
public function store(Request $request)
{
$data = $request->validate([
'parent_id' => ['nullable', 'integer'],
'name' => ['required', 'string', 'max:50'],
'is_active' => ['required', 'in:0,1'],
'slug' => ['required', 'string', 'max:100', 'regex:/^[a-z0-9\-]+$/'],
'search_keywords' => ['nullable', 'string'], // 추가
]);
$res = $this->service->storeCategory(
input: $data,
actorAdminId: (int) auth('admin')->id(),
ip: (string) $request->ip(),
ua: (string) ($request->userAgent() ?? ''),
);
if (!($res['ok'] ?? false)) {
return redirect()->back()->withInput()->with('toast', [
'type' => 'danger',
'title' => '등록 실패',
'message' => $res['message'],
]);
}
return redirect()->route('admin.categories.index')->with('toast', [
'type' => 'success',
'title' => '등록 완료',
'message' => $res['message'],
]);
}
public function update(int $id, Request $request)
{
$data = $request->validate([
'name' => ['required', 'string', 'max:50'],
'is_active' => ['required', 'in:0,1'],
'slug' => ['required', 'string', 'max:100', 'regex:/^[a-z0-9\-]+$/'],
'search_keywords' => ['nullable', 'string'], // 추가
]);
$res = $this->service->updateCategory(
id: $id,
input: $data,
actorAdminId: (int) auth('admin')->id(),
ip: (string) $request->ip(),
ua: (string) ($request->userAgent() ?? ''),
);
if (!($res['ok'] ?? false)) {
return redirect()->back()->with('toast', [
'type' => 'danger',
'title' => '수정 실패',
'message' => $res['message'],
]);
}
return redirect()->route('admin.categories.index')->with('toast', [
'type' => 'success',
'title' => '수정 완료',
'message' => $res['message'],
]);
}
public function destroy(int $id, Request $request)
{
$res = $this->service->deleteCategory(
id: $id,
actorAdminId: (int) auth('admin')->id(),
ip: (string) $request->ip(),
ua: (string) ($request->userAgent() ?? ''),
);
if (!($res['ok'] ?? false)) {
return redirect()->back()->with('toast', [
'type' => 'danger',
'title' => '삭제 실패',
'message' => $res['message'],
]);
}
return redirect()->route('admin.categories.index')->with('toast', [
'type' => 'success',
'title' => '삭제 완료',
'message' => $res['message'],
]);
}
public function updateSort(Request $request)
{
$data = $request->validate([
'ids' => ['required', 'array'],
'ids.*' => ['integer'],
]);
$res = $this->service->updateSort($data['ids'], (int) auth('admin')->id());
return response()->json($res);
}
}

View File

@ -0,0 +1,97 @@
<?php
namespace App\Http\Controllers\Admin\Product;
use App\Services\Admin\Product\AdminFeeService;
use Illuminate\Http\Request;
final class AdminFeeController
{
public function __construct(
private readonly AdminFeeService $service,
) {}
public function index()
{
$data = $this->service->getFeeData();
return view('admin.product.fee.index', $data);
}
public function updateBuybackPolicy(Request $request)
{
$data = $request->validate([
'customer_fee_rate' => ['required', 'numeric', 'min:0', 'max:100'],
'bank_fee_type' => ['required', 'in:FLAT,PERCENT'],
'bank_fee_value' => ['required', 'numeric', 'min:0'],
]);
$res = $this->service->updateBuybackPolicy($data, (int) auth('admin')->id(), $request->ip(), $request->userAgent() ?? '');
return redirect()->back()->with('toast', [
'type' => $res['ok'] ? 'success' : 'danger',
'title' => '정책 수정',
'message' => $res['message'],
]);
}
public function storePayment(Request $request)
{
$data = $request->validate([
'code' => ['required', 'string', 'max:30'],
'name' => ['required', 'string', 'max:50'],
'display_name' => ['required', 'string', 'max:100'],
'customer_fee_rate' => ['required', 'numeric', 'min:0', 'max:100'],
'pg_fee_rate' => ['required', 'numeric', 'min:0', 'max:100'],
'is_active' => ['required', 'in:0,1'],
]);
$res = $this->service->storePaymentMethod($data, (int) auth('admin')->id(), $request->ip(), $request->userAgent() ?? '');
return redirect()->route('admin.fees.index')->with('toast', [
'type' => $res['ok'] ? 'success' : 'danger',
'title' => '결제 수단 등록',
'message' => $res['message'],
]);
}
public function updatePayment(int $id, Request $request)
{
$data = $request->validate([
'name' => ['required', 'string', 'max:50'],
'display_name' => ['required', 'string', 'max:100'],
'customer_fee_rate' => ['required', 'numeric', 'min:0', 'max:100'],
'pg_fee_rate' => ['required', 'numeric', 'min:0', 'max:100'],
'is_active' => ['required', 'in:0,1'],
]);
$res = $this->service->updatePaymentMethod($id, $data, (int) auth('admin')->id(), $request->ip(), $request->userAgent() ?? '');
return redirect()->route('admin.fees.index')->with('toast', [
'type' => $res['ok'] ? 'success' : 'danger',
'title' => '결제 수단 수정',
'message' => $res['message'],
]);
}
public function updatePaymentSort(Request $request)
{
$data = $request->validate([
'ids' => ['required', 'array'],
'ids.*' => ['integer'],
]);
$res = $this->service->updatePaymentSort($data['ids'], (int) auth('admin')->id());
return response()->json($res);
}
public function destroyPayment(int $id, Request $request)
{
$res = $this->service->deletePaymentMethod($id, (int) auth('admin')->id(), $request->ip(), $request->userAgent() ?? '');
return redirect()->route('admin.fees.index')->with('toast', [
'type' => $res['ok'] ? 'success' : 'danger',
'title' => '결제 수단 삭제',
'message' => $res['message'],
]);
}
}

View File

@ -0,0 +1,89 @@
<?php
namespace App\Http\Controllers\Admin\Product;
use App\Services\Admin\Product\AdminMediaService;
use Illuminate\Http\Request;
final class AdminMediaController
{
public function __construct(
private readonly AdminMediaService $service,
) {}
public function index(Request $request)
{
$data = $this->service->list($request->all());
return view('admin.product.media.index', $data);
}
public function store(Request $request)
{
$request->validate([
'folder_name' => ['required', 'string', 'max:50'],
'media_name' => ['nullable', 'string', 'max:100'],
'images' => ['required', 'array', 'max:20'],
'images.*' => ['image', 'mimes:jpeg,png,jpg,gif,webp', 'max:2048'],
]);
$actorId = (int) auth('admin')->id();
$ip = (string) $request->ip();
$ua = (string) ($request->userAgent() ?? '');
$customName = $request->input('media_name');
$successCount = 0;
// [수정된 부분] foreach의 $index는 가끔 string으로 인식될 수 있습니다.
foreach ($request->file('images') as $index => $file) {
$res = $this->service->uploadImage(
$request->only('folder_name'),
$file,
$customName,
(int) $index, // ✅ [핵심 수정] (int)를 붙여서 정수로 강제 변환
$actorId,
$ip,
$ua
);
if ($res['ok']) {
$successCount++;
}
}
return redirect()->back()->with('toast', [
'type' => 'success',
'title' => '업로드 완료',
'message' => "{$successCount}장의 이미지가 업로드 되었습니다.",
]);
}
public function destroy(int $id, Request $request)
{
$res = $this->service->deleteImage($id, (int) auth('admin')->id(), $request->ip(), $request->userAgent() ?? '');
return redirect()->back()->with('toast', [
'type' => $res['ok'] ? 'success' : 'danger',
'title' => '알림',
'message' => $res['message'],
]);
}
/**
* 추후 '상품 등록' 모달 창에서 AJAX로 이미지를 불러오기 위한 API 엔드포인트
*/
public function apiList(Request $request)
{
$data = $this->service->list($request->all());
return response()->json([
'data' => $data['page']->items(),
'links' => (string) $data['page']->links(),
]);
}
public function updateName(int $id, Request $request)
{
$data = $request->validate(['name' => ['required', 'string', 'max:150']]);
$res = $this->service->updateMediaName($id, $data['name'], (int)auth('admin')->id(), $request->ip(), $request->userAgent() ?? '');
return response()->json($res);
}
}

View File

@ -0,0 +1,92 @@
<?php
namespace App\Http\Controllers\Admin\Product;
use App\Services\Admin\Product\AdminPinService;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Crypt;
final class AdminPinController
{
public function __construct(
private readonly AdminPinService $service,
) {}
// 핀 관리 메인 화면 (권종 선택 후 진입)
public function index(int $productId, int $skuId, Request $request)
{
$product = DB::table('gc_products')->find($productId);
$sku = DB::table('gc_product_skus')->find($skuId);
if (!$product || !$sku) {
return redirect()->back()->with('toast', ['type' => 'danger', 'message' => '잘못된 접근입니다.']);
}
// ✅ 수정된 Service 반환값(배열) 받기
$pinData = $this->service->getPinsBySku($skuId, $request->all());
$pins = $pinData['pins'];
$stats = $pinData['stats']; // 통계 데이터
$pins->getCollection()->transform(function ($pin) {
try {
$decrypted = Crypt::decryptString($pin->pin_code);
$length = strlen($decrypted);
$pin->decrypted_code = substr($decrypted, 0, 4) . str_repeat('*', max(0, $length - 8)) . substr($decrypted, -4);
} catch (\Exception $e) {
$pin->decrypted_code = '복호화 에러';
}
return $pin;
});
// 뷰로 변수 묶어서 전달
return view('admin.product.pins.index', compact('product', 'sku', 'pins', 'stats'));
}
// 핀 회수 및 다운로드 요청 처리
public function recallBulk(Request $request, int $productId, int $skuId)
{
$request->validate([
'amount_type' => ['required', 'string'],
'custom_amount' => ['nullable', 'integer', 'min:1'],
'zip_password' => ['required', 'string', 'min:4'],
]);
$amount = (int)$request->input('custom_amount', 0);
if ($request->input('amount_type') !== 'ALL' && $amount <= 0) {
$amount = (int)$request->input('amount_type');
}
$res = $this->service->recallPins($skuId, $request->input('amount_type'), $amount, $request->input('zip_password'));
if (!$res['ok']) {
return redirect()->back()->with('toast', ['type' => 'danger', 'title' => '오류', 'message' => $res['message']]);
}
// 성공 시 응답으로 파일을 다운로드하고, 전송이 끝나면 서버에서 임시 zip 파일을 즉시 삭제(deleteFileAfterSend)
return response()->download($res['file_path'], $res['file_name'])->deleteFileAfterSend(true);
}
// 대량 등록 처리
public function storeBulk(Request $request, int $productId, int $skuId)
{
$request->validate([
'bulk_text' => ['required', 'string'],
'expiry_date' => ['nullable', 'date'],
]);
$res = $this->service->bulkRegisterPins(
$productId,
$skuId,
$request->input('bulk_text'),
$request->input('expiry_date')
);
if (!$res['ok']) {
return redirect()->back()->withInput()->with('toast', ['type' => 'danger', 'title' => '등록 실패', 'message' => $res['message']]);
}
return redirect()->route('admin.pins.index', ['productId' => $productId, 'skuId' => $skuId])
->with('toast', ['type' => 'success', 'title' => '완료', 'message' => $res['message']]);
}
}

View File

@ -0,0 +1,109 @@
<?php
namespace App\Http\Controllers\Admin\Product;
use App\Services\Admin\Product\AdminProductService;
use Illuminate\Http\Request;
final class AdminProductController
{
public function __construct(
private readonly AdminProductService $service,
) {}
public function index(Request $request)
{
session(['product_list_url' => request()->fullUrl()]);
$data = $this->service->list($request->all());
return view('admin.product.products.index', $data);
}
public function create()
{
$data = $this->service->getFormData();
return view('admin.product.products.create', $data);
}
public function store(Request $request)
{
$data = $this->validateProduct($request);
$res = $this->service->storeProduct($data, (int)auth('admin')->id(), $request->ip(), $request->userAgent() ?? '');
if (!$res['ok']) {
return redirect()->back()->withInput()->with('toast', ['type' => 'danger', 'title' => '저장 실패', 'message' => $res['message']]);
}
return redirect()->route('admin.products.index')->with('toast', ['type' => 'success', 'title' => '완료', 'message' => $res['message']]);
}
private function validateProduct(Request $request): array
{
return $request->validate([
'category_id' => ['required', 'integer'],
'name' => ['required', 'string', 'max:150'],
'thumbnail_media_id' => ['nullable', 'integer'],
'status' => ['required', 'in:ACTIVE,HIDDEN,SOLD_OUT'],
'product_type' => ['required', 'in:ONLINE,DELIVERY'],
'is_buyback_allowed' => ['required', 'in:0,1'],
'payment_methods' => ['required', 'array', 'min:1'],
'payment_methods.*' => ['integer'],
'is_always_on_sale' => ['required', 'in:0,1'],
'sales_start_at' => ['nullable', 'date'],
'sales_end_at' => ['nullable', 'date', 'after_or_equal:sales_start_at'],
'purchase_type' => ['required', 'in:SINGLE,MULTI_QTY,MULTI_SKU'],
'min_buy_qty' => ['required', 'integer', 'min:1'],
'max_buy_qty' => ['required', 'integer', 'min:0'],
'max_buy_amount' => ['required', 'integer', 'min:0'],
'description' => ['nullable', 'string'],
'guide' => ['nullable', 'string'],
'warning' => ['nullable', 'string'],
// 다중 SKU 밸리데이션
'skus' => ['required', 'array', 'min:1'],
'skus.*.id' => ['nullable', 'integer'],
'skus.*.name' => ['required', 'string', 'max:50'],
'skus.*.tax_type' => ['required', 'in:TAX,FREE'],
'skus.*.face_value' => ['required', 'integer', 'min:0'],
'skus.*.discount_type' => ['required', 'in:PERCENT,FIXED'],
'skus.*.discount_value' => ['required', 'integer', 'min:0'],
'skus.*.sales_method' => ['required', 'in:OWN_PIN,API_LINK'],
'skus.*.api_provider_id' => ['nullable', 'integer', 'required_if:skus.*.sales_method,API_LINK'],
'skus.*.api_product_code' => ['nullable', 'string', 'max:50', 'required_if:skus.*.sales_method,API_LINK'],
'skus.*.is_active' => ['required', 'in:0,1'],
], [
'payment_methods.required' => '최소 1개 이상의 결제 수단을 선택해주세요.',
'skus.required' => '최소 1개 이상의 권종을 등록해야 합니다.',
'skus.*.api_provider_id.required_if' => 'API 연동 시 연동사를 선택해야 합니다.',
'skus.*.api_product_code.required_if' => 'API 연동 시 상품 코드를 선택해야 합니다.',
]);
}
// ... (기존 index, create, store 유지) ...
public function edit(int $id)
{
$data = $this->service->getEditData($id);
if (empty($data)) {
return redirect()->route('admin.products.index')->with('toast', ['type' => 'danger', 'title' => '오류', 'message' => '상품을 찾을 수 없습니다.']);
}
return view('admin.product.products.edit', $data);
}
public function update(int $id, Request $request)
{
$data = $this->validateProduct($request);
$res = $this->service->updateProduct($id, $data, (int)auth('admin')->id(), $request->ip(), $request->userAgent() ?? '');
if (!$res['ok']) {
return redirect()->back()->withInput()->with('toast', ['type' => 'danger', 'title' => '수정 실패', 'message' => $res['message']]);
}
return redirect()->route('admin.products.index')->with('toast', ['type' => 'success', 'title' => '완료', 'message' => $res['message']]);
}
}

View File

@ -0,0 +1,95 @@
<?php
namespace App\Http\Controllers\Admin\Product;
use App\Services\Admin\Product\AdminSaleCodeService;
use Illuminate\Http\Request;
final class AdminSaleCodeController
{
public function __construct(
private readonly AdminSaleCodeService $service,
) {}
public function index()
{
$tree = $this->service->getGroupedTree();
return view('admin.product.salecode.index', ['tree' => $tree]);
}
// --- Provider ---
public function storeProvider(Request $request)
{
$data = $request->validate([
'code' => ['required', 'string', 'max:30'],
'name' => ['required', 'string', 'max:50'],
'is_active' => ['required', 'in:0,1'],
]);
$res = $this->service->storeProvider($data, (int) auth('admin')->id(), $request->ip(), $request->userAgent() ?? '');
return redirect()->back()->with('toast', ['type' => $res['ok'] ? 'success' : 'danger', 'title' => '알림', 'message' => $res['message']]);
}
public function updateProvider(int $id, Request $request)
{
$data = $request->validate([
'name' => ['required', 'string', 'max:50'],
'is_active' => ['required', 'in:0,1'],
]);
$res = $this->service->updateProvider($id, $data, (int) auth('admin')->id(), $request->ip(), $request->userAgent() ?? '');
return redirect()->back()->with('toast', ['type' => $res['ok'] ? 'success' : 'danger', 'title' => '알림', 'message' => $res['message']]);
}
public function destroyProvider(int $id, Request $request)
{
$res = $this->service->deleteProvider($id, (int) auth('admin')->id(), $request->ip(), $request->userAgent() ?? '');
return redirect()->back()->with('toast', ['type' => $res['ok'] ? 'success' : 'danger', 'title' => '알림', 'message' => $res['message']]);
}
// --- Product Code ---
public function storeCode(Request $request)
{
$data = $request->validate([
'provider_id' => ['required', 'integer'],
'api_code' => ['required', 'string', 'max:50'],
'name' => ['required', 'string', 'max:100'],
'is_active' => ['required', 'in:0,1'],
]);
$res = $this->service->storeCode($data, (int) auth('admin')->id(), $request->ip(), $request->userAgent() ?? '');
return redirect()->back()->with('toast', ['type' => $res['ok'] ? 'success' : 'danger', 'title' => '알림', 'message' => $res['message']]);
}
public function updateCode(int $id, Request $request)
{
$data = $request->validate([
'provider_id' => ['required', 'integer'],
'api_code' => ['required', 'string', 'max:50'],
'name' => ['required', 'string', 'max:100'],
'is_active' => ['required', 'in:0,1'],
]);
$res = $this->service->updateCode($id, $data, (int) auth('admin')->id(), $request->ip(), $request->userAgent() ?? '');
return redirect()->back()->with('toast', ['type' => $res['ok'] ? 'success' : 'danger', 'title' => '알림', 'message' => $res['message']]);
}
public function updateCodeSort(Request $request)
{
$data = $request->validate([
'ids' => ['required', 'array'],
'ids.*' => ['integer'],
]);
$res = $this->service->updateCodeSort($data['ids'], (int) auth('admin')->id());
return response()->json($res);
}
public function destroyCode(int $id, Request $request)
{
$res = $this->service->deleteCode($id, (int) auth('admin')->id(), $request->ip(), $request->userAgent() ?? '');
return redirect()->back()->with('toast', ['type' => $res['ok'] ? 'success' : 'danger', 'title' => '알림', 'message' => $res['message']]);
}
}

View File

@ -0,0 +1,42 @@
<?php
namespace App\Http\Controllers\Web\Product;
use App\Http\Controllers\Controller;
use App\Services\Product\ProductService;
use Illuminate\Http\Request;
class ProductController extends Controller
{
public function __construct(protected ProductService $service) {}
public function index(Request $request, $category = null)
{
$search = $request->query('search'); // ?search=키워드 추출
$data = $this->service->getListSceneData($category, $search);
return view('web.product.list.index', [
'pageTitle' => $data['currentCategoryName'],
'subnavItems' => $data['menuItems'],
'subnavActive' => null, // 검색 시에는 사이드바 활성화 해제 권장
'products' => $data['products'],
'searchKeyword' => $search
]);
}
public function show(int $id)
{
$data = $this->service->getProductDetailData($id);
if (!$data) abort(404);
return view('web.product.detail.index', [
'product' => $data['product'],
'skus' => $data['skus'],
'payments' => $data['payments'],
'subnavItems' => $data['menuItems'],
'subnavActive' => $data['product']->category_id,
'pageTitle' => $data['product']->name,
]);
}
}

View File

@ -0,0 +1,69 @@
<?php
namespace App\Repositories\Admin\Product;
use Illuminate\Support\Facades\DB;
final class AdminCategoryRepository
{
/**
* 전체 카테고리 목록 (1, 2 모두 포함, 정렬순)
*/
public function getAllCategories(): array
{
return DB::table('gc_categories')
->orderByRaw('ISNULL(parent_id) DESC') // 1차(parent_id=null)를 먼저
->orderBy('parent_id')
->orderBy('sort_order')
->get()
->map(fn($r) => (array)$r)
->all();
}
public function findCategory(int $id): ?object
{
return DB::table('gc_categories')->where('id', $id)->first();
}
public function insertCategory(array $data): int
{
$data['created_at'] = now()->format('Y-m-d H:i:s');
$data['updated_at'] = $data['created_at'];
return DB::table('gc_categories')->insertGetId($data);
}
public function updateCategory(int $id, array $data): bool
{
$data['updated_at'] = now()->format('Y-m-d H:i:s');
return DB::table('gc_categories')->where('id', $id)->update($data) > 0;
}
public function deleteCategory(int $id): bool
{
return DB::table('gc_categories')->where('id', $id)->delete() > 0;
}
/**
* 하위 카테고리 개수 확인 (삭제 방어용)
*/
public function countChildren(int $parentId): int
{
return DB::table('gc_categories')->where('parent_id', $parentId)->count();
}
/**
* 해당 카테고리에 속한 상품 개수 확인 (삭제 방어용 - 향후 상품 테이블도 gc_products 사용)
*/
public function countProducts(int $categoryId): int
{
return DB::table('gc_products')->where('category_id', $categoryId)->count();
}
public function updateSortOrder(int $id, int $sortOrder): bool
{
return DB::table('gc_categories')
->where('id', $id)
->update(['sort_order' => $sortOrder]) > 0;
}
}

View File

@ -0,0 +1,65 @@
<?php
namespace App\Repositories\Admin\Product;
use Illuminate\Support\Facades\DB;
final class AdminFeeRepository
{
// ==========================================
// 1. 결제 수단 (Payment Methods)
// ==========================================
public function getPaymentMethods(): array
{
return DB::table('gc_payment_methods')
->orderBy('sort_order')
->get()
->map(fn($r) => (array)$r)
->all();
}
public function findPaymentMethod(int $id): ?object
{
return DB::table('gc_payment_methods')->where('id', $id)->first();
}
public function insertPaymentMethod(array $data): int
{
$data['created_at'] = now()->format('Y-m-d H:i:s');
$data['updated_at'] = $data['created_at'];
return DB::table('gc_payment_methods')->insertGetId($data);
}
public function updatePaymentMethod(int $id, array $data): bool
{
$data['updated_at'] = now()->format('Y-m-d H:i:s');
return DB::table('gc_payment_methods')->where('id', $id)->update($data) > 0;
}
public function deletePaymentMethod(int $id): bool
{
return DB::table('gc_payment_methods')->where('id', $id)->delete() > 0;
}
public function updatePaymentSortOrder(int $id, int $sortOrder): bool
{
return DB::table('gc_payment_methods')
->where('id', $id)
->update(['sort_order' => $sortOrder]) > 0;
}
// ==========================================
// 2. 매입/출금 정책 (Buyback Policy)
// ==========================================
public function getBuybackPolicy(): ?object
{
// 항상 1번 row를 기본 정책으로 사용
return DB::table('gc_buyback_policies')->where('id', 1)->first();
}
public function updateBuybackPolicy(array $data): bool
{
$data['updated_at'] = now()->format('Y-m-d H:i:s');
return DB::table('gc_buyback_policies')->where('id', 1)->update($data) > 0;
}
}

View File

@ -0,0 +1,64 @@
<?php
namespace App\Repositories\Admin\Product;
use Illuminate\Support\Facades\DB;
use Illuminate\Pagination\LengthAwarePaginator;
final class AdminMediaRepository
{
/**
* 갤러리 목록 페이징 (최신순)
*/
public function paginateMedia(array $filters, int $perPage = 24): LengthAwarePaginator
{
$q = DB::table('gc_media_library');
// 폴더명 필터
if (!empty($filters['folder'])) {
$q->where('folder_name', $filters['folder']);
}
return $q->orderByDesc('id')->paginate($perPage)->withQueryString();
}
/**
* 사용 중인 폴더명 목록 추출 (Select Box용)
*/
public function getDistinctFolders(): array
{
return DB::table('gc_media_library')
->select('folder_name')
->distinct()
->orderBy('folder_name')
->pluck('folder_name')
->all();
}
public function findMedia(int $id): ?object
{
return DB::table('gc_media_library')->where('id', $id)->first();
}
public function insertMedia(array $data): int
{
$data['created_at'] = now()->format('Y-m-d H:i:s');
$data['updated_at'] = $data['created_at'];
return DB::table('gc_media_library')->insertGetId($data);
}
public function deleteMedia(int $id): bool
{
return DB::table('gc_media_library')->where('id', $id)->delete() > 0;
}
public function updateMediaName(int $id, string $name): bool
{
return DB::table('gc_media_library')
->where('id', $id)
->update([
'name' => $name,
'updated_at' => now()->format('Y-m-d H:i:s')
]) > 0;
}
}

View File

@ -0,0 +1,55 @@
<?php
namespace App\Repositories\Admin\Product;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Facades\DB;
final class AdminPinRepository
{
/**
* 특정 권종(SKU) 목록 페이징 검색
*/
public function paginatePins(int $skuId, array $filters, int $perPage = 50): LengthAwarePaginator
{
$q = DB::table('gc_pins')->where('sku_id', $skuId);
if (!empty($filters['status'])) {
$q->where('status', $filters['status']);
}
// ✨ [추가] 핀 번호 정확도 검색 (해시값 이용)
if (!empty($filters['q'])) {
$searchHash = hash('sha256', trim($filters['q']));
$q->where('pin_hash', $searchHash);
}
return $q->orderByDesc('id')->paginate($perPage)->withQueryString();
}
/**
* [추가] 재고 통계 가져오기
*/
public function getPinStats(int $skuId): array
{
$stats = DB::table('gc_pins')
->select('status', DB::raw('count(*) as cnt'))
->where('sku_id', $skuId)
->groupBy('status')
->pluck('cnt', 'status')->toArray();
$total = array_sum($stats);
return [
'total' => $total,
'AVAILABLE' => $stats['AVAILABLE'] ?? 0,
'SOLD' => $stats['SOLD'] ?? 0,
'HOLD' => $stats['HOLD'] ?? 0,
'RECALLED' => $stats['RECALLED'] ?? 0,
'EXPIRED' => $stats['EXPIRED'] ?? 0,
];
}
public function insertPinsBulk(array $pinsData): int
{
return DB::table('gc_pins')->insertOrIgnore($pinsData);
}
}

View File

@ -0,0 +1,148 @@
<?php
namespace App\Repositories\Admin\Product;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Facades\DB;
final class AdminProductRepository
{
/**
* 상품 리스트 페이징 다중 검색 필터
*/
public function paginateProducts(array $filters, int $perPage = 30): \Illuminate\Pagination\LengthAwarePaginator
{
$q = DB::table('gc_products as p')
->leftJoin('gc_categories as c2', 'p.category_id', '=', 'c2.id')
->leftJoin('gc_categories as c1', 'c2.parent_id', '=', 'c1.id')
->leftJoin('gc_media_library as m', 'p.thumbnail_media_id', '=', 'm.id');
if (!empty($filters['cate'])) {
$cateId = (int) $filters['cate'];
$q->where(function ($query) use ($cateId) {
$query->where('p.category_id', $cateId)->orWhere('c2.parent_id', $cateId);
});
}
if (!empty($filters['status'])) {
$q->where('p.status', $filters['status']);
}
if (!empty($filters['q'])) {
$q->where('p.name', 'like', '%' . $filters['q'] . '%');
}
$paginator = $q->select([
'p.*',
'm.file_path as thumbnail_path',
DB::raw("IF(c1.name IS NOT NULL, CONCAT(c1.name, ' > ', c2.name), c2.name) as category_path")
])->orderByDesc('p.id')->paginate($perPage)->withQueryString();
// ✨ [추가] 리스트에 출력된 상품들의 모든 권종(SKU)과 핀 재고 정보 병합
$productIds = collect($paginator->items())->pluck('id')->toArray();
if (!empty($productIds)) {
// 권종 정보 및 API 연동사 이름 가져오기
$skus = DB::table('gc_product_skus as s')
->leftJoin('gc_api_providers as api', 's.api_provider_id', '=', 'api.id') // ✨ 실제 테이블명으로 변경
->select('s.*', 'api.name as provider_name')
->whereIn('s.product_id', $productIds)
->orderBy('s.sort_order')
->get();
// 자사핀 재고 카운트 (전체 / 판매대기)
$skuIds = $skus->pluck('id')->toArray();
$pinStats = [];
if (!empty($skuIds)) {
$pinStats = DB::table('gc_pins')
->select('sku_id',
DB::raw("COUNT(*) as total_pins"),
DB::raw("SUM(IF(status = 'AVAILABLE', 1, 0)) as available_pins")
)
->whereIn('sku_id', $skuIds)
->groupBy('sku_id')
->get()->keyBy('sku_id')->toArray();
}
// 상품별로 권종 그룹핑
$skusByProduct = [];
foreach ($skus as $sku) {
$sku->total_pins = $pinStats[$sku->id]->total_pins ?? 0;
$sku->available_pins = $pinStats[$sku->id]->available_pins ?? 0;
$skusByProduct[$sku->product_id][] = $sku;
}
// 페이지네이터 아이템에 skus 배열 주입
foreach ($paginator->items() as $item) {
$item->skus = $skusByProduct[$item->id] ?? [];
}
}
return $paginator;
}
public function findProduct(int $id): ?object
{
return DB::table('gc_products')->where('id', $id)->first();
}
public function insertProduct(array $data): int
{
$data['created_at'] = now()->format('Y-m-d H:i:s');
$data['updated_at'] = $data['created_at'];
return DB::table('gc_products')->insertGetId($data);
}
public function insertSkus(array $skusData): bool
{
$now = now()->format('Y-m-d H:i:s');
foreach ($skusData as &$sku) {
$sku['created_at'] = $now;
$sku['updated_at'] = $now;
}
return DB::table('gc_product_skus')->insert($skusData);
}
public function updateProduct(int $id, array $data): bool
{
$data['updated_at'] = now()->format('Y-m-d H:i:s');
return DB::table('gc_products')->where('id', $id)->update($data) > 0;
}
public function getSkusByProductId(int $productId): array
{
return DB::table('gc_product_skus')
->where('product_id', $productId)
->orderBy('sort_order')
->get()
->map(fn($r) => (array)$r)
->toArray();
}
public function insertSkuGetId(array $skuData): int
{
$now = now()->format('Y-m-d H:i:s');
$skuData['created_at'] = $now;
$skuData['updated_at'] = $now;
return DB::table('gc_product_skus')->insertGetId($skuData);
}
public function updateSku(int $id, array $data): bool
{
$data['updated_at'] = now()->format('Y-m-d H:i:s');
return DB::table('gc_product_skus')->where('id', $id)->update($data) > 0;
}
public function deleteSkusNotIn(int $productId, array $keepIds): bool
{
if (empty($keepIds)) {
return DB::table('gc_product_skus')->where('product_id', $productId)->delete() > 0;
}
return DB::table('gc_product_skus')
->where('product_id', $productId)
->whereNotIn('id', $keepIds)
->delete() > 0;
}
public function deleteProduct(int $id): bool
{
return DB::table('gc_products')->where('id', $id)->delete() > 0;
}
}

View File

@ -0,0 +1,91 @@
<?php
namespace App\Repositories\Admin\Product;
use Illuminate\Support\Facades\DB;
final class AdminSaleCodeRepository
{
// ==========================================
// 1. Providers (API 연동사)
// ==========================================
public function getAllProviders(): array
{
return DB::table('gc_api_providers')
->orderBy('sort_order')
->get()
->map(fn($r) => (array)$r)
->all();
}
public function findProvider(int $id): ?object
{
return DB::table('gc_api_providers')->where('id', $id)->first();
}
public function insertProvider(array $data): int
{
$data['created_at'] = now()->format('Y-m-d H:i:s');
$data['updated_at'] = $data['created_at'];
return DB::table('gc_api_providers')->insertGetId($data);
}
public function updateProvider(int $id, array $data): bool
{
$data['updated_at'] = now()->format('Y-m-d H:i:s');
return DB::table('gc_api_providers')->where('id', $id)->update($data) > 0;
}
public function deleteProvider(int $id): bool
{
return DB::table('gc_api_providers')->where('id', $id)->delete() > 0;
}
// ==========================================
// 2. Product Codes (상품 코드 매핑)
// ==========================================
public function getAllCodes(): array
{
return DB::table('gc_api_product_codes')
->orderBy('provider_id')
->orderBy('sort_order')
->get()
->map(fn($r) => (array)$r)
->all();
}
public function findCode(int $id): ?object
{
return DB::table('gc_api_product_codes')->where('id', $id)->first();
}
public function insertCode(array $data): int
{
$data['created_at'] = now()->format('Y-m-d H:i:s');
$data['updated_at'] = $data['created_at'];
return DB::table('gc_api_product_codes')->insertGetId($data);
}
public function updateCode(int $id, array $data): bool
{
$data['updated_at'] = now()->format('Y-m-d H:i:s');
return DB::table('gc_api_product_codes')->where('id', $id)->update($data) > 0;
}
public function deleteCode(int $id): bool
{
return DB::table('gc_api_product_codes')->where('id', $id)->delete() > 0;
}
public function updateCodeSortOrder(int $id, int $sortOrder): bool
{
return DB::table('gc_api_product_codes')
->where('id', $id)
->update(['sort_order' => $sortOrder]) > 0;
}
public function countCodesByProvider(int $providerId): int
{
return DB::table('gc_api_product_codes')->where('provider_id', $providerId)->count();
}
}

View File

@ -0,0 +1,111 @@
<?php
namespace App\Repositories\Product;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Carbon;
class ProductRepository
{
/**
* 왼쪽 메뉴용 활성 카테고리 목록 조회
*/
public function getActiveCategories()
{
return DB::table('gc_categories')
->where('is_active', 1)
->orderBy('sort_order', 'asc')
->get();
}
/**
* 카테고리별/전체 상품 목록 조회 (판매 기간 상태 검증 포함)
*/
// app/Repositories/Product/ProductRepository.php
public function getActiveProducts($categoryIdOrSlug = null, $search = null)
{
$now = now();
$query = DB::table('gc_products as p')
->select('p.*', 'm.file_path as thumb_path')
->leftJoin('gc_media_library as m', 'p.thumbnail_media_id', '=', 'm.id')
->where('p.status', 'ACTIVE');
// 1. 검색어가 있을 경우 (통합 검색)
if ($search) {
$query->where(function ($q) use ($search) {
// 상품명 검색
$q->where('p.name', 'LIKE', "%{$search}%");
// 카테고리 연관 검색 (이름, 슬러그, 해시태그 키워드)
$q->orWhereExists(function ($sub) use ($search) {
$sub->select(DB::raw(1))
->from('gc_categories as c')
->whereColumn('c.id', 'p.category_id')
->where(function ($sq) use ($search) {
$sq->where('c.name', 'LIKE', "%{$search}%")
->orWhere('c.slug', 'LIKE', "%{$search}%")
->orWhere('c.search_keywords', 'LIKE', "%{$search}%"); // # 제거된 검색어도 LIKE로 매칭
});
});
});
}
if ($categoryIdOrSlug && !$search) {
$categoryQuery = DB::table('gc_categories');
if (is_numeric($categoryIdOrSlug)) {
$categoryQuery->where('id', $categoryIdOrSlug);
} else {
$categoryQuery->where('slug', $categoryIdOrSlug);
}
$targetCategory = $categoryQuery->first();
if ($targetCategory) {
$categoryIds = DB::table('gc_categories')
->where('id', $targetCategory->id)
->orWhere('parent_id', $targetCategory->id)
->pluck('id');
$query->whereIn('p.category_id', $categoryIds);
}
}
return $query->orderBy('p.id', 'desc')->get();
}
public function getProductById(int $id)
{
return DB::table('gc_products as p')
->select('p.*', 'm.file_path as thumb_path')
->leftJoin('gc_media_library as m', 'p.thumbnail_media_id', '=', 'm.id')
->where('p.id', $id)
->where('p.status', 'ACTIVE')
->first();
}
/**
* 상품에 속한 권종(SKU) 목록 조회
*/
public function getSkusByProductId(int $productId)
{
return DB::table('gc_product_skus')
->where('product_id', $productId)
->where('is_active', 1)
->orderBy('sort_order', 'asc')
->get();
}
/**
* 특정 결제 수단 정보 조회
*/
public function getPaymentMethodsByIds(array $ids)
{
if (empty($ids)) return collect();
return DB::table('gc_payment_methods')
->whereIn('id', $ids)
->where('is_active', 1)
->orderBy('sort_order', 'asc')
->get();
}
}

View File

@ -0,0 +1,156 @@
<?php
namespace App\Services\Admin\Product;
use App\Repositories\Admin\Product\AdminCategoryRepository;
use App\Services\Admin\AdminAuditService;
use Illuminate\Support\Facades\DB;
final class AdminCategoryService
{
public function __construct(
private readonly AdminCategoryRepository $repo,
private readonly AdminAuditService $audit,
) {}
/**
* 카테고리 트리 구조 생성 (화면 출력용)
*/
public function getCategoryTree(): array
{
$all = $this->repo->getAllCategories();
$tree = [];
$children = [];
// 1차, 2차 분리
foreach ($all as $c) {
if (empty($c['parent_id'])) {
$c['children'] = [];
$tree[$c['id']] = $c;
} else {
$children[$c['parent_id']][] = $c;
}
}
// 조립
foreach ($children as $parentId => $childArray) {
if (isset($tree[$parentId])) {
$tree[$parentId]['children'] = $childArray;
}
}
return array_values($tree);
}
public function storeCategory(array $input, int $actorAdminId, string $ip, string $ua): array
{
try {
return DB::transaction(function () use ($input, $actorAdminId, $ip, $ua) {
$data = [
'parent_id' => !empty($input['parent_id']) ? (int)$input['parent_id'] : null,
'name' => trim($input['name']),
'slug' => strtolower(trim($input['slug'])),
'search_keywords' => trim($input['search_keywords'] ?? ''),
'sort_order' => 0,
'is_active' => (int)($input['is_active'] ?? 1),
];
$newId = $this->repo->insertCategory($data);
// 감사 로그
$this->audit->log(
actorAdminId: $actorAdminId,
action: 'admin.category.create',
targetType: 'category',
targetId: $newId,
before: null,
after: $data,
ip: $ip,
ua: $ua,
);
return ['ok' => true, 'message' => '카테고리가 등록되었습니다.'];
});
} catch (\Throwable $e) {
return ['ok' => false, 'message' => '저장 중 오류가 발생했습니다.'];
}
}
public function updateCategory(int $id, array $input, int $actorAdminId, string $ip, string $ua): array
{
try {
return DB::transaction(function () use ($id, $input, $actorAdminId, $ip, $ua) {
$before = $this->repo->findCategory($id);
if (!$before) return ['ok' => false, 'message' => '카테고리를 찾을 수 없습니다.'];
$data = [
'name' => trim($input['name'] ?? $before->name),
'is_active' => isset($input['is_active']) ? (int)$input['is_active'] : $before->is_active,
'slug' => strtolower(trim($input['slug'])),
'search_keywords' => trim($input['search_keywords'] ?? ''),
];
$this->repo->updateCategory($id, $data);
$this->audit->log(
actorAdminId: $actorAdminId,
action: 'admin.category.update',
targetType: 'category',
targetId: $id,
before: (array)$before,
after: array_merge((array)$before, $data),
ip: $ip,
ua: $ua,
);
return ['ok' => true, 'message' => '변경되었습니다.'];
});
} catch (\Throwable $e) {
return ['ok' => false, 'message' => '수정 중 오류가 발생했습니다.'];
}
}
public function deleteCategory(int $id, int $actorAdminId, string $ip, string $ua): array
{
$before = $this->repo->findCategory($id);
if (!$before) return ['ok' => false, 'message' => '존재하지 않는 카테고리입니다.'];
// 삭제 방어 1: 하위 카테고리가 있는지
if (empty($before->parent_id) && $this->repo->countChildren($id) > 0) {
return ['ok' => false, 'message' => '하위 2차 카테고리가 존재하여 삭제할 수 없습니다.'];
}
$this->repo->deleteCategory($id);
$this->audit->log(
actorAdminId: $actorAdminId,
action: 'admin.category.delete',
targetType: 'category',
targetId: $id,
before: (array)$before,
after: null,
ip: $ip,
ua: $ua,
);
return ['ok' => true, 'message' => '삭제되었습니다.'];
}
/**
* [추가] AJAX 정렬 순서 일괄 업데이트
*/
public function updateSort(array $ids, int $actorAdminId): array
{
try {
DB::transaction(function () use ($ids) {
// 배열의 인덱스(0, 1, 2...)를 기준으로 +1 하여 sort_order 업데이트
foreach ($ids as $index => $id) {
$this->repo->updateSortOrder((int)$id, $index + 1);
}
});
return ['ok' => true];
} catch (\Throwable $e) {
return ['ok' => false, 'message' => '정렬 중 오류가 발생했습니다.'];
}
}
}

View File

@ -0,0 +1,127 @@
<?php
namespace App\Services\Admin\Product;
use App\Repositories\Admin\Product\AdminFeeRepository;
use App\Services\Admin\AdminAuditService;
use Illuminate\Support\Facades\DB;
final class AdminFeeService
{
public function __construct(
private readonly AdminFeeRepository $repo,
private readonly AdminAuditService $audit,
) {}
public function getFeeData(): array
{
return [
'paymentMethods' => $this->repo->getPaymentMethods(),
'buybackPolicy' => $this->repo->getBuybackPolicy(),
];
}
public function updateBuybackPolicy(array $input, int $actorAdminId, string $ip, string $ua): array
{
try {
$before = $this->repo->getBuybackPolicy();
$data = [
'customer_fee_rate' => (float)$input['customer_fee_rate'],
'bank_fee_type' => $input['bank_fee_type'],
'bank_fee_value' => (float)$input['bank_fee_value'],
];
$this->repo->updateBuybackPolicy($data);
$this->audit->log(
actorAdminId: $actorAdminId,
action: 'admin.fee.buyback.update',
targetType: 'buyback_policy',
targetId: 1,
before: (array)$before,
after: array_merge((array)$before, $data),
ip: $ip,
ua: $ua,
);
return ['ok' => true, 'message' => '매입(출금) 정책이 업데이트되었습니다.'];
} catch (\Throwable $e) {
return ['ok' => false, 'message' => '출금 정책 저장 중 오류가 발생했습니다.'];
}
}
public function storePaymentMethod(array $input, int $actorAdminId, string $ip, string $ua): array
{
try {
return DB::transaction(function () use ($input, $actorAdminId, $ip, $ua) {
$data = [
'code' => strtoupper(trim($input['code'])),
'name' => trim($input['name']),
'display_name' => trim($input['display_name']),
'customer_fee_rate' => (float)$input['customer_fee_rate'],
'pg_fee_rate' => (float)$input['pg_fee_rate'],
'is_active' => (int)$input['is_active'],
'sort_order' => 0, // 기본값
];
$newId = $this->repo->insertPaymentMethod($data);
$this->audit->log($actorAdminId, 'admin.fee.payment.create', 'payment_method', $newId, null, $data, $ip, $ua);
return ['ok' => true, 'message' => '결제 수단이 등록되었습니다.'];
});
} catch (\Throwable $e) {
return ['ok' => false, 'message' => '결제수단 코드가 중복되거나 저장 오류가 발생했습니다.'];
}
}
public function updatePaymentMethod(int $id, array $input, int $actorAdminId, string $ip, string $ua): array
{
try {
return DB::transaction(function () use ($id, $input, $actorAdminId, $ip, $ua) {
$before = $this->repo->findPaymentMethod($id);
if (!$before) return ['ok' => false, 'message' => '결제 수단을 찾을 수 없습니다.'];
$data = [
'name' => trim($input['name']),
'display_name' => trim($input['display_name']),
'customer_fee_rate' => (float)$input['customer_fee_rate'],
'pg_fee_rate' => (float)$input['pg_fee_rate'],
'is_active' => (int)$input['is_active'],
];
$this->repo->updatePaymentMethod($id, $data);
$this->audit->log($actorAdminId, 'admin.fee.payment.update', 'payment_method', $id, (array)$before, array_merge((array)$before, $data), $ip, $ua);
return ['ok' => true, 'message' => '결제 수단이 수정되었습니다.'];
});
} catch (\Throwable $e) {
return ['ok' => false, 'message' => '수정 중 오류가 발생했습니다.'];
}
}
public function updatePaymentSort(array $ids, int $actorAdminId): array
{
try {
DB::transaction(function () use ($ids) {
foreach ($ids as $index => $id) {
$this->repo->updatePaymentSortOrder((int)$id, $index + 1);
}
});
return ['ok' => true];
} catch (\Throwable $e) {
return ['ok' => false, 'message' => '정렬 중 오류가 발생했습니다.'];
}
}
public function deletePaymentMethod(int $id, int $actorAdminId, string $ip, string $ua): array
{
$before = $this->repo->findPaymentMethod($id);
if (!$before) return ['ok' => false, 'message' => '결제 수단을 찾을 수 없습니다.'];
$this->repo->deletePaymentMethod($id);
$this->audit->log($actorAdminId, 'admin.fee.payment.delete', 'payment_method', $id, (array)$before, null, $ip, $ua);
return ['ok' => true, 'message' => '삭제되었습니다.'];
}
}

View File

@ -0,0 +1,189 @@
<?php
namespace App\Services\Admin\Product;
use App\Repositories\Admin\Product\AdminMediaRepository;
use App\Services\Admin\AdminAuditService;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
// ✅ 이미지 처리 라이브러리 (Intervention Image v3)
use Intervention\Image\ImageManager;
use Intervention\Image\Drivers\Gd\Driver;
final class AdminMediaService
{
private ImageManager $manager;
public function __construct(
private readonly AdminMediaRepository $repo,
private readonly AdminAuditService $audit,
) {
// GD 드라이버 사용 (대부분의 호스팅/서버 환경에서 기본 지원)
$this->manager = new ImageManager(new Driver());
}
/**
* 미디어 목록 조회 (필터 페이징)
*/
public function list(array $filters): array
{
return [
'page' => $this->repo->paginateMedia($filters),
'folders' => $this->repo->getDistinctFolders(),
'currentFolder' => $filters['folder'] ?? '',
];
}
/**
* 이미지 업로드 WebP 변환 저장
* * @param array $input 폴더명 입력값
* @param UploadedFile $file 업로드된 파일 객체
* @param string|null $customName 관리자가 지정한 이미지 이름 (없으면 원본명 사용)
* @param int $fileIndex 다중 업로드 순번 (이름 뒤에 _1, _2 붙이기 위함)
*/
public function uploadImage(array $input, UploadedFile $file, ?string $customName, int $fileIndex, int $actorAdminId, string $ip, string $ua): array
{
try {
// 1. 폴더명 보안 처리 (영문, 숫자, 언더바, 하이픈만 허용)
$folderName = trim($input['folder_name'] ?? 'default');
$folderName = preg_replace('/[^a-zA-Z0-9_\-]/', '', $folderName);
if (empty($folderName)) $folderName = 'default';
$originalName = $file->getClientOriginalName();
// 2. 이미지 처리 (WebP 변환)
// Intervention Image를 통해 파일을 읽고 WebP 포맷(퀄리티 80)으로 인코딩
$image = $this->manager->read($file);
$encoded = $image->toWebp(quality: 80);
// 3. 저장할 파일명 생성 (랜덤 해시 + .webp)
$hashName = Str::random(40) . '.webp';
$savePath = "product/{$folderName}/{$hashName}";
// 4. 스토리지 저장 (public 디스크)
// put() 메소드를 사용하여 변환된 바이너리 데이터를 저장
$isSaved = Storage::disk('public')->put($savePath, (string) $encoded);
if (!$isSaved) {
return ['ok' => false, 'message' => '스토리지 파일 저장에 실패했습니다.'];
}
// 5. 관리용 이름(Name) 결정
if (!empty($customName)) {
// 다중 파일인 경우 뒤에 순번을 붙임 (예: 문화상품권_1)
$finalName = $fileIndex > 0 ? $customName . '_' . $fileIndex : $customName;
} else {
// 이름 지정 안 함 -> 원본 파일명에서 확장자 제거하고 사용
$finalName = pathinfo($originalName, PATHINFO_FILENAME);
}
// 6. DB 저장 데이터 준비
$data = [
'folder_name' => $folderName,
'name' => $finalName,
'original_name' => $originalName,
'file_name' => $hashName,
'file_path' => '/storage/' . $savePath, // 웹 접근 경로
'file_size' => strlen((string) $encoded), // 변환된 파일 크기
'file_ext' => 'webp', // 확장자는 무조건 webp
];
// 7. 트랜잭션 DB 저장 및 로그
DB::transaction(function () use ($data, $actorAdminId, $ip, $ua) {
$newId = $this->repo->insertMedia($data);
$this->audit->log(
actorAdminId: $actorAdminId,
action: 'admin.media.upload',
targetType: 'media_library',
targetId: $newId,
before: null,
after: $data,
ip: $ip,
ua: $ua
);
});
return ['ok' => true, 'message' => '업로드 성공'];
} catch (\Throwable $e) {
// 이미지 변환 실패(엑셀 파일 등) 혹은 기타 오류 처리
return ['ok' => false, 'message' => '이미지 처리 중 오류 발생: ' . $e->getMessage()];
}
}
/**
* 이미지 이름(관리용) 수정
*/
public function updateMediaName(int $id, string $newName, int $actorAdminId, string $ip, string $ua): array
{
$before = $this->repo->findMedia($id);
if (!$before) return ['ok' => false, 'message' => '이미지를 찾을 수 없습니다.'];
try {
DB::transaction(function () use ($id, $newName, $before, $actorAdminId, $ip, $ua) {
$this->repo->updateMediaName($id, trim($newName));
$after = (array)$before;
$after['name'] = trim($newName);
$this->audit->log(
actorAdminId: $actorAdminId,
action: 'admin.media.rename',
targetType: 'media_library',
targetId: $id,
before: (array)$before,
after: $after,
ip: $ip,
ua: $ua
);
});
return ['ok' => true, 'message' => '이름이 변경되었습니다.'];
} catch (\Throwable $e) {
return ['ok' => false, 'message' => '이름 변경 중 오류가 발생했습니다.'];
}
}
/**
* 이미지 삭제 (DB + 물리 파일)
*/
public function deleteImage(int $id, int $actorAdminId, string $ip, string $ua): array
{
$media = $this->repo->findMedia($id);
if (!$media) return ['ok' => false, 'message' => '이미지를 찾을 수 없습니다.'];
try {
// 1. DB 삭제 및 로그 (트랜잭션)
DB::transaction(function () use ($id, $media, $actorAdminId, $ip, $ua) {
$this->repo->deleteMedia($id);
$this->audit->log(
actorAdminId: $actorAdminId,
action: 'admin.media.delete',
targetType: 'media_library',
targetId: $id,
before: (array)$media,
after: null,
ip: $ip,
ua: $ua
);
});
// 2. 물리 파일 삭제 (DB 삭제 성공 시 실행)
// /storage/product/... -> product/... 로 변환하여 삭제
$storagePath = str_replace('/storage/', '', $media->file_path);
if (Storage::disk('public')->exists($storagePath)) {
Storage::disk('public')->delete($storagePath);
}
return ['ok' => true, 'message' => '이미지가 삭제되었습니다.'];
} catch (\Throwable $e) {
return ['ok' => false, 'message' => '삭제 처리 중 오류가 발생했습니다.'];
}
}
}

View File

@ -0,0 +1,169 @@
<?php
namespace App\Services\Admin\Product;
use App\Repositories\Admin\Product\AdminPinRepository;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB;
final class AdminPinService
{
public function __construct(
private readonly AdminPinRepository $repo,
) {}
public function getPinsBySku(int $skuId, array $filters): array
{
return [
'pins' => $this->repo->paginatePins($skuId, $filters),
'stats' => $this->repo->getPinStats($skuId),
];
}
public function recallPins(int $skuId, string $amountType, int $customAmount, string $zipPassword): array
{
// 1. 회수할 수량 결정
$limit = ($amountType === 'ALL') ? PHP_INT_MAX : $customAmount;
if ($limit <= 0) return ['ok' => false, 'message' => '회수할 수량을 1 이상 입력해주세요.'];
DB::beginTransaction();
try {
// 2. 가장 나중에 등록된(ID가 큰) AVAILABLE 핀들 가져오기 (LIFO)
$pinsToRecall = DB::table('gc_pins')
->where('sku_id', $skuId)
->where('status', 'AVAILABLE')
->orderByDesc('id')
->limit($limit)
->get();
if ($pinsToRecall->isEmpty()) {
DB::rollBack();
return ['ok' => false, 'message' => '회수할 수 있는 판매대기(AVAILABLE) 핀이 없습니다.'];
}
// 3. 상태를 'RECALLED'로 업데이트
$pinIds = $pinsToRecall->pluck('id')->toArray();
DB::table('gc_pins')->whereIn('id', $pinIds)->update([
'status' => 'RECALLED',
'updated_at' => now()->format('Y-m-d H:i:s')
]);
// 4. 복호화하여 CSV 데이터 텍스트 생성
// 엑셀에서 한글 깨짐 방지를 위해 BOM(\xEF\xBB\xBF) 추가
$csvData = "\xEF\xBB\xBF" . "ID,PIN_CODE,FACE_VALUE,BUY_PRICE\n";
foreach($pinsToRecall as $pin) {
try {
$decrypted = \Illuminate\Support\Facades\Crypt::decryptString($pin->pin_code);
$csvData .= "{$pin->id},{$decrypted},{$pin->face_value},{$pin->buy_price}\n";
} catch (\Exception $e) {
$csvData .= "{$pin->id},DECRYPT_ERROR,{$pin->face_value},{$pin->buy_price}\n";
}
}
// 5. ZIP 파일 생성 (라라벨 storage/app/public 임시 저장)
$fileName = "recalled_pins_sku_{$skuId}_" . date('YmdHis') . ".zip";
$zipPath = storage_path('app/public/' . $fileName);
$zip = new \ZipArchive();
if ($zip->open($zipPath, \ZipArchive::CREATE | \ZipArchive::OVERWRITE) === true) {
$zip->addFromString('recalled_pins.csv', $csvData);
// 비밀번호 암호화 세팅 (PHP 7.2 이상 지원)
$zip->setPassword($zipPassword);
$zip->setEncryptionName('recalled_pins.csv', \ZipArchive::EM_AES_256);
$zip->close();
} else {
DB::rollBack();
return ['ok' => false, 'message' => 'ZIP 파일 생성에 실패했습니다. (서버의 php-zip 확장을 확인하세요)'];
}
DB::commit();
return ['ok' => true, 'file_path' => $zipPath, 'file_name' => $fileName, 'count' => count($pinIds)];
} catch (\Throwable $e) {
DB::rollBack();
return ['ok' => false, 'message' => '회수 처리 중 오류가 발생했습니다: ' . $e->getMessage()];
}
}
/**
* 텍스트 대량 등록 로직
*/
public function bulkRegisterPins(int $productId, int $skuId, string $bulkText, string $expiryDate = null): array
{
// 1. 텍스트를 줄바꿈 단위로 분리 (빈 줄 제거)
$lines = array_filter(array_map('trim', explode("\n", $bulkText)));
if (empty($lines)) {
return ['ok' => false, 'message' => '입력된 핀 정보가 없습니다.'];
}
$insertData = [];
$now = now()->format('Y-m-d H:i:s');
$successCount = 0;
$duplicateCount = 0;
$errorLines = 0;
foreach ($lines as $line) {
// 2. 콤마(,) 또는 탭(\t)으로 데이터 분리 (예: 핀번호, 정액, 원가)
// 정규식을 사용해 콤마나 탭 여러 개를 하나로 취급
$parts = preg_split('/[\t,]+/', $line);
// 데이터 파싱 (최소한 핀번호는 있어야 함)
$pinCode = trim($parts[0] ?? '');
if (empty($pinCode)) continue;
$faceValue = isset($parts[1]) ? (int)preg_replace('/[^0-9]/', '', $parts[1]) : 0;
$buyPrice = isset($parts[2]) ? (int)preg_replace('/[^0-9]/', '', $parts[2]) : 0;
// 3. 마진율 계산 (정액금액이 0보다 클 때만 계산)
$marginRate = 0.00;
if ($faceValue > 0) {
$marginRate = round((($faceValue - $buyPrice) / $faceValue) * 100, 2);
}
// 4. 보안 처리 (암호화 및 해시)
$encryptedPin = Crypt::encryptString($pinCode);
$pinHash = hash('sha256', $pinCode); // 중복 검사용 해시
$insertData[] = [
'product_id' => $productId,
'sku_id' => $skuId,
'pin_code' => $encryptedPin,
'pin_hash' => $pinHash,
'status' => 'AVAILABLE',
'face_value' => $faceValue,
'buy_price' => $buyPrice,
'margin_rate' => $marginRate,
'expiry_date' => $expiryDate ?: null,
'created_at' => $now,
'updated_at' => $now,
];
}
// 5. 500개씩 청크(Chunk)로 나누어 일괄 Insert (서버 과부하 방지)
DB::beginTransaction();
try {
$chunks = array_chunk($insertData, 500);
$totalInserted = 0;
foreach ($chunks as $chunk) {
// insertOrIgnore 덕분에 중복 핀은 무시되고 성공한 갯수만 반환됨
$insertedRows = $this->repo->insertPinsBulk($chunk);
$totalInserted += $insertedRows;
}
DB::commit();
$duplicateCount = count($insertData) - $totalInserted;
return [
'ok' => true,
'message' => "{$totalInserted}개의 핀이 등록되었습니다. (중복 제외: {$duplicateCount}개)"
];
} catch (\Throwable $e) {
DB::rollBack();
return ['ok' => false, 'message' => '대량 등록 중 DB 오류가 발생했습니다: ' . $e->getMessage()];
}
}
}

View File

@ -0,0 +1,226 @@
<?php
namespace App\Services\Admin\Product;
use App\Repositories\Admin\Product\AdminProductRepository;
use App\Services\Admin\AdminAuditService;
use App\Services\Admin\Product\AdminCategoryService;
use App\Services\Admin\Product\AdminSaleCodeService;
use Illuminate\Support\Facades\DB;
final class AdminProductService
{
public function __construct(
private readonly AdminProductRepository $repo,
private readonly AdminAuditService $audit,
private readonly AdminCategoryService $categoryService,
private readonly AdminSaleCodeService $saleCodeService,
) {}
public function list(array $filters): array
{
return [
'products' => $this->repo->paginateProducts($filters),
'categories' => $this->categoryService->getCategoryTree(),
];
}
public function getFormData(): array
{
return [
'categories' => $this->categoryService->getCategoryTree(),
'providers' => $this->saleCodeService->getGroupedTree(),
'payments' => DB::table('gc_payment_methods')->where('is_active', 1)->orderBy('sort_order')->get()->toArray(),
'folders' => DB::table('gc_media_library')->select('folder_name')->distinct()->orderBy('folder_name')->pluck('folder_name')->toArray(),
];
}
public function storeProduct(array $input, int $actorAdminId, string $ip, string $ua): array
{
try {
return DB::transaction(function () use ($input, $actorAdminId, $ip, $ua) {
// 1. 상품(부모) 데이터 준비
$productData = [
'category_id' => (int)$input['category_id'],
'name' => trim($input['name']),
'thumbnail_media_id' => !empty($input['thumbnail_media_id']) ? (int)$input['thumbnail_media_id'] : null,
'purchase_type' => $input['purchase_type'] ?? 'MULTI_SKU',
'min_buy_qty' => (int)($input['min_buy_qty'] ?? 1),
'max_buy_qty' => (int)($input['max_buy_qty'] ?? 0),
'max_buy_amount' => (int)($input['max_buy_amount'] ?? 0),
'product_type' => $input['product_type'] ?? 'ONLINE',
'allowed_payments' => json_encode($input['payment_methods'] ?? []), // 결제수단 JSON 저장
'is_buyback_allowed' => (int)($input['is_buyback_allowed'] ?? 0),
'status' => $input['status'] ?? 'HIDDEN',
'is_always_on_sale' => (int)$input['is_always_on_sale'],
'sales_start_at' => $input['is_always_on_sale'] == 0 ? ($input['sales_start_at'] ?? null) : null,
'sales_end_at' => $input['is_always_on_sale'] == 0 ? ($input['sales_end_at'] ?? null) : null,
'description' => trim($input['description'] ?? ''),
'guide' => trim($input['guide'] ?? ''),
'warning' => trim($input['warning'] ?? ''),
];
$newProductId = $this->repo->insertProduct($productData);
// 2. 권종(SKU) 데이터 준비
$skusData = [];
if (!empty($input['skus']) && is_array($input['skus'])) {
foreach ($input['skus'] as $index => $sku) {
$faceValue = (int)$sku['face_value'];
$discountType = $sku['discount_type'];
$discountValue = (int)$sku['discount_value'];
// 할인율/정액할인 최종가 계산 로직 분기
if ($discountType === 'PERCENT') {
$finalPrice = (int)($faceValue - ($faceValue * ($discountValue / 100)));
} else {
$finalPrice = (int)($faceValue - $discountValue);
}
// 최소 0원 방어
if ($finalPrice < 0) $finalPrice = 0;
$salesMethod = $sku['sales_method'];
$skusData[] = [
'product_id' => $newProductId,
'name' => trim($sku['name']),
'tax_type' => $sku['tax_type'] ?? 'TAX',
'face_value' => $faceValue,
'discount_type' => $discountType,
'discount_value' => $discountValue,
'final_price' => $finalPrice,
'sales_method' => $salesMethod,
'api_provider_id' => $salesMethod === 'API_LINK' ? (int)$sku['api_provider_id'] : null,
'api_product_code' => $salesMethod === 'API_LINK' ? trim($sku['api_product_code']) : null,
'is_active' => (int)$sku['is_active'],
'sort_order' => $index + 1,
];
}
$this->repo->insertSkus($skusData);
}
$this->audit->log($actorAdminId, 'admin.product.create', 'product', $newProductId, null, ['product' => $productData, 'skus' => $skusData], $ip, $ua);
return ['ok' => true, 'message' => '상품 및 권종이 성공적으로 등록되었습니다.'];
});
} catch (\Throwable $e) {
return ['ok' => false, 'message' => '저장 중 오류가 발생했습니다: ' . $e->getMessage()];
}
}
public function getEditData(int $id): array
{
$product = $this->repo->findProduct($id);
if (!$product) return [];
$skus = $this->repo->getSkusByProductId($id);
// JSON으로 저장된 결제수단 배열로 복원
$product->allowed_payments = json_decode($product->allowed_payments ?? '[]', true);
// 썸네일 정보 가져오기
$thumbnail = null;
if ($product->thumbnail_media_id) {
$thumbnail = DB::table('gc_media_library')->where('id', $product->thumbnail_media_id)->first();
}
// 마스터 데이터(카테고리, 연동사)와 합쳐서 반환
return array_merge($this->getFormData(), [
'product' => $product,
'skus' => $skus,
'thumbnail' => $thumbnail
]);
}
public function updateProduct(int $id, array $input, int $actorAdminId, string $ip, string $ua): array
{
try {
return DB::transaction(function () use ($id, $input, $actorAdminId, $ip, $ua) {
$before = $this->repo->findProduct($id);
if (!$before) return ['ok' => false, 'message' => '상품을 찾을 수 없습니다.'];
// 1. 부모 상품 업데이트
$productData = [
'category_id' => (int)$input['category_id'],
'name' => trim($input['name']),
'thumbnail_media_id' => !empty($input['thumbnail_media_id']) ? (int)$input['thumbnail_media_id'] : null,
'purchase_type' => $input['purchase_type'] ?? 'MULTI_SKU',
'min_buy_qty' => (int)($input['min_buy_qty'] ?? 1),
'max_buy_qty' => (int)($input['max_buy_qty'] ?? 0),
'max_buy_amount' => (int)($input['max_buy_amount'] ?? 0),
'product_type' => $input['product_type'] ?? 'ONLINE',
'allowed_payments' => json_encode($input['payment_methods'] ?? []),
'is_buyback_allowed' => (int)($input['is_buyback_allowed'] ?? 0),
'status' => $input['status'] ?? 'HIDDEN',
'is_always_on_sale' => (int)$input['is_always_on_sale'],
'sales_start_at' => $input['is_always_on_sale'] == 0 ? ($input['sales_start_at'] ?? null) : null,
'sales_end_at' => $input['is_always_on_sale'] == 0 ? ($input['sales_end_at'] ?? null) : null,
'description' => trim($input['description'] ?? ''),
'guide' => trim($input['guide'] ?? ''),
'warning' => trim($input['warning'] ?? ''),
];
$this->repo->updateProduct($id, $productData);
// 2. 권종(SKU) 업데이트 동기화 (Sync)
$keepSkuIds = []; // 유지/수정된 권종 ID 보관용
if (!empty($input['skus']) && is_array($input['skus'])) {
foreach ($input['skus'] as $index => $sku) {
$skuId = !empty($sku['id']) ? (int)$sku['id'] : null;
$faceValue = (int)$sku['face_value'];
$discountType = $sku['discount_type'];
$discountValue = (int)$sku['discount_value'];
// 최종가 계산
$finalPrice = $discountType === 'PERCENT'
? (int)($faceValue - ($faceValue * ($discountValue / 100)))
: (int)($faceValue - $discountValue);
if ($finalPrice < 0) $finalPrice = 0;
$salesMethod = $sku['sales_method'];
$skuData = [
'product_id' => $id,
'name' => trim($sku['name']),
'tax_type' => $sku['tax_type'] ?? 'TAX',
'face_value' => $faceValue,
'discount_type' => $discountType,
'discount_value' => $discountValue,
'final_price' => $finalPrice,
'sales_method' => $salesMethod,
'api_provider_id' => $salesMethod === 'API_LINK' ? (int)$sku['api_provider_id'] : null,
'api_product_code' => $salesMethod === 'API_LINK' ? trim($sku['api_product_code']) : null,
'is_active' => (int)$sku['is_active'],
'sort_order' => $index + 1,
];
if ($skuId) {
// 기존에 있던 권종이면 Update
$this->repo->updateSku($skuId, $skuData);
$keepSkuIds[] = $skuId;
} else {
// 수정 폼에서 새로 추가된 권종이면 Insert
$newSkuId = $this->repo->insertSkuGetId($skuData);
$keepSkuIds[] = $newSkuId;
}
}
}
// 3. 삭제 처리 (폼에서 [X 삭제] 버튼으로 지운 권종들)
$this->repo->deleteSkusNotIn($id, $keepSkuIds);
$this->audit->log($actorAdminId, 'admin.product.update', 'product', $id, (array)$before, $productData, $ip, $ua);
return ['ok' => true, 'message' => '상품 및 권종 정보가 성공적으로 수정되었습니다.'];
});
} catch (\Throwable $e) {
return ['ok' => false, 'message' => '수정 중 오류가 발생했습니다: ' . $e->getMessage()];
}
}
}

View File

@ -0,0 +1,169 @@
<?php
namespace App\Services\Admin\Product;
use App\Repositories\Admin\Product\AdminSaleCodeRepository;
use App\Services\Admin\AdminAuditService;
use Illuminate\Support\Facades\DB;
final class AdminSaleCodeService
{
public function __construct(
private readonly AdminSaleCodeRepository $repo,
private readonly AdminAuditService $audit,
) {}
/**
* 화면 출력용 트리 구조 생성 (연동사 -> 하위 상품코드)
*/
public function getGroupedTree(): array
{
$providers = $this->repo->getAllProviders();
$codes = $this->repo->getAllCodes();
$tree = [];
foreach ($providers as $p) {
$p['children'] = [];
$tree[$p['id']] = $p;
}
foreach ($codes as $c) {
if (isset($tree[$c['provider_id']])) {
$tree[$c['provider_id']]['children'][] = $c;
}
}
return array_values($tree);
}
// --- Provider ---
public function storeProvider(array $input, int $actorAdminId, string $ip, string $ua): array
{
try {
return DB::transaction(function () use ($input, $actorAdminId, $ip, $ua) {
$data = [
'code' => strtoupper(trim($input['code'])),
'name' => trim($input['name']),
'is_active' => (int)$input['is_active'],
'sort_order' => 0,
];
$newId = $this->repo->insertProvider($data);
$this->audit->log($actorAdminId, 'admin.sale_code.provider.create', 'api_provider', $newId, null, $data, $ip, $ua);
return ['ok' => true, 'message' => '연동사가 등록되었습니다.'];
});
} catch (\Throwable $e) {
return ['ok' => false, 'message' => '연동사 코드가 중복되거나 저장 오류가 발생했습니다.'];
}
}
public function updateProvider(int $id, array $input, int $actorAdminId, string $ip, string $ua): array
{
try {
return DB::transaction(function () use ($id, $input, $actorAdminId, $ip, $ua) {
$before = $this->repo->findProvider($id);
if (!$before) return ['ok' => false, 'message' => '연동사를 찾을 수 없습니다.'];
$data = [
'name' => trim($input['name']),
'is_active' => (int)$input['is_active'],
];
$this->repo->updateProvider($id, $data);
$this->audit->log($actorAdminId, 'admin.sale_code.provider.update', 'api_provider', $id, (array)$before, array_merge((array)$before, $data), $ip, $ua);
return ['ok' => true, 'message' => '연동사가 수정되었습니다.'];
});
} catch (\Throwable $e) {
return ['ok' => false, 'message' => '수정 중 오류가 발생했습니다.'];
}
}
public function deleteProvider(int $id, int $actorAdminId, string $ip, string $ua): array
{
$before = $this->repo->findProvider($id);
if (!$before) return ['ok' => false, 'message' => '존재하지 않는 연동사입니다.'];
if ($this->repo->countCodesByProvider($id) > 0) {
return ['ok' => false, 'message' => '하위 상품 코드가 존재하여 삭제할 수 없습니다. 상품 코드를 먼저 삭제해주세요.'];
}
$this->repo->deleteProvider($id);
$this->audit->log($actorAdminId, 'admin.sale_code.provider.delete', 'api_provider', $id, (array)$before, null, $ip, $ua);
return ['ok' => true, 'message' => '삭제되었습니다.'];
}
// --- Product Code ---
public function storeCode(array $input, int $actorAdminId, string $ip, string $ua): array
{
try {
return DB::transaction(function () use ($input, $actorAdminId, $ip, $ua) {
$data = [
'provider_id' => (int)$input['provider_id'],
'api_code' => strtoupper(trim($input['api_code'])),
'name' => trim($input['name']),
'is_active' => (int)$input['is_active'],
'sort_order' => 0,
];
$newId = $this->repo->insertCode($data);
$this->audit->log($actorAdminId, 'admin.sale_code.code.create', 'api_code', $newId, null, $data, $ip, $ua);
return ['ok' => true, 'message' => '상품 코드가 등록되었습니다.'];
});
} catch (\Throwable $e) {
return ['ok' => false, 'message' => '해당 연동사에 중복된 코드가 있거나 저장 오류가 발생했습니다.'];
}
}
public function updateCode(int $id, array $input, int $actorAdminId, string $ip, string $ua): array
{
try {
return DB::transaction(function () use ($id, $input, $actorAdminId, $ip, $ua) {
$before = $this->repo->findCode($id);
if (!$before) return ['ok' => false, 'message' => '상품 코드를 찾을 수 없습니다.'];
$data = [
'provider_id' => (int)$input['provider_id'],
'api_code' => strtoupper(trim($input['api_code'])),
'name' => trim($input['name']),
'is_active' => (int)$input['is_active'],
];
$this->repo->updateCode($id, $data);
$this->audit->log($actorAdminId, 'admin.sale_code.code.update', 'api_code', $id, (array)$before, array_merge((array)$before, $data), $ip, $ua);
return ['ok' => true, 'message' => '상품 코드가 수정되었습니다.'];
});
} catch (\Throwable $e) {
return ['ok' => false, 'message' => '수정 중 중복 오류가 발생했습니다.'];
}
}
public function updateCodeSort(array $ids, int $actorAdminId): array
{
try {
DB::transaction(function () use ($ids) {
foreach ($ids as $index => $id) {
$this->repo->updateCodeSortOrder((int)$id, $index + 1);
}
});
return ['ok' => true];
} catch (\Throwable $e) {
return ['ok' => false, 'message' => '정렬 중 오류가 발생했습니다.'];
}
}
public function deleteCode(int $id, int $actorAdminId, string $ip, string $ua): array
{
$before = $this->repo->findCode($id);
if (!$before) return ['ok' => false, 'message' => '상품 코드를 찾을 수 없습니다.'];
$this->repo->deleteCode($id);
$this->audit->log($actorAdminId, 'admin.sale_code.code.delete', 'api_code', $id, (array)$before, null, $ip, $ua);
return ['ok' => true, 'message' => '상품 코드가 삭제되었습니다.'];
}
}

View File

@ -0,0 +1,75 @@
<?php
namespace App\Services\Product;
use App\Repositories\Product\ProductRepository;
class ProductService
{
public function __construct(protected ProductRepository $repository) {}
public function getListSceneData($categoryOrSlug, $search = null)
{
$allCategories = $this->repository->getActiveCategories();
$parentCategories = $allCategories->whereNull('parent_id')->values();
// 현재 선택된 카테고리 객체 찾기
$currentCategory = is_numeric($categoryOrSlug)
? $allCategories->where('id', $categoryOrSlug)->first()
: $allCategories->where('slug', $categoryOrSlug)->first();
$menuItems = $parentCategories->map(function($parent) use ($allCategories, $currentCategory) {
$children = $allCategories->where('parent_id', $parent->id)->values();
return [
'id' => $parent->id,
'slug' => $parent->slug, // 슬러그 추가
'label' => $parent->name,
'url' => route('web.product.index', ['category' => $parent->slug]), // ID 대신 Slug로 URL 생성
'children' => $children->map(function($child) {
return [
'id' => $child->id,
'slug' => $child->slug,
'label' => $child->name,
'url' => route('web.product.index', ['category' => $child->slug]),
];
})
];
});
$products = $this->repository->getActiveProducts($categoryOrSlug, $search);
return [
'menuItems' => $menuItems,
'products' => $products,
'currentCategory' => $currentCategory,
'currentCategoryName' => $search ? "'{$search}' 검색 결과" : ($currentCategory ? $currentCategory->name : '전체 상품')
];
}
/**
* 상품 상세 페이지 데이터 구성
*/
public function getProductDetailData(int $id)
{
$product = $this->repository->getProductById($id);
if (!$product) return null;
$skus = $this->repository->getSkusByProductId($id);
// 허용된 결제수단 조회
$allowedPayments = json_decode($product->allowed_payments ?? '[]', true);
$payments = $this->repository->getPaymentMethodsByIds($allowedPayments);
// 사이드바용 카테고리 데이터 (기존 메서드 활용)
$menuData = $this->getListSceneData(null);
return [
'product' => $product,
'skus' => $skus,
'payments' => $payments,
'menuItems' => $menuData['menuItems'],
];
}
}

View File

@ -7,6 +7,7 @@
"license": "MIT", "license": "MIT",
"require": { "require": {
"php": "^8.2", "php": "^8.2",
"intervention/image": "^3.11",
"laravel/fortify": "^1.34", "laravel/fortify": "^1.34",
"laravel/framework": "^12.0", "laravel/framework": "^12.0",
"laravel/tinker": "^2.10.1", "laravel/tinker": "^2.10.1",

146
composer.lock generated
View File

@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "5489ef2cd1c47137632ceb06cdbab30b", "content-hash": "0806303423926715aa8f817719c01f46",
"packages": [ "packages": [
{ {
"name": "bacon/bacon-qr-code", "name": "bacon/bacon-qr-code",
@ -1218,6 +1218,150 @@
], ],
"time": "2025-08-22T14:27:06+00:00" "time": "2025-08-22T14:27:06+00:00"
}, },
{
"name": "intervention/gif",
"version": "4.2.4",
"source": {
"type": "git",
"url": "https://github.com/Intervention/gif.git",
"reference": "c3598a16ebe7690cd55640c44144a9df383ea73c"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/Intervention/gif/zipball/c3598a16ebe7690cd55640c44144a9df383ea73c",
"reference": "c3598a16ebe7690cd55640c44144a9df383ea73c",
"shasum": ""
},
"require": {
"php": "^8.1"
},
"require-dev": {
"phpstan/phpstan": "^2.1",
"phpunit/phpunit": "^10.0 || ^11.0 || ^12.0",
"slevomat/coding-standard": "~8.0",
"squizlabs/php_codesniffer": "^3.8"
},
"type": "library",
"autoload": {
"psr-4": {
"Intervention\\Gif\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Oliver Vogel",
"email": "oliver@intervention.io",
"homepage": "https://intervention.io/"
}
],
"description": "Native PHP GIF Encoder/Decoder",
"homepage": "https://github.com/intervention/gif",
"keywords": [
"animation",
"gd",
"gif",
"image"
],
"support": {
"issues": "https://github.com/Intervention/gif/issues",
"source": "https://github.com/Intervention/gif/tree/4.2.4"
},
"funding": [
{
"url": "https://paypal.me/interventionio",
"type": "custom"
},
{
"url": "https://github.com/Intervention",
"type": "github"
},
{
"url": "https://ko-fi.com/interventionphp",
"type": "ko_fi"
}
],
"time": "2026-01-04T09:27:23+00:00"
},
{
"name": "intervention/image",
"version": "3.11.6",
"source": {
"type": "git",
"url": "https://github.com/Intervention/image.git",
"reference": "5f6d27d9fd56312c47f347929e7ac15345c605a1"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/Intervention/image/zipball/5f6d27d9fd56312c47f347929e7ac15345c605a1",
"reference": "5f6d27d9fd56312c47f347929e7ac15345c605a1",
"shasum": ""
},
"require": {
"ext-mbstring": "*",
"intervention/gif": "^4.2",
"php": "^8.1"
},
"require-dev": {
"mockery/mockery": "^1.6",
"phpstan/phpstan": "^2.1",
"phpunit/phpunit": "^10.0 || ^11.0 || ^12.0",
"slevomat/coding-standard": "~8.0",
"squizlabs/php_codesniffer": "^3.8"
},
"suggest": {
"ext-exif": "Recommended to be able to read EXIF data properly."
},
"type": "library",
"autoload": {
"psr-4": {
"Intervention\\Image\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Oliver Vogel",
"email": "oliver@intervention.io",
"homepage": "https://intervention.io"
}
],
"description": "PHP Image Processing",
"homepage": "https://image.intervention.io",
"keywords": [
"gd",
"image",
"imagick",
"resize",
"thumbnail",
"watermark"
],
"support": {
"issues": "https://github.com/Intervention/image/issues",
"source": "https://github.com/Intervention/image/tree/3.11.6"
},
"funding": [
{
"url": "https://paypal.me/interventionio",
"type": "custom"
},
{
"url": "https://github.com/Intervention",
"type": "github"
},
{
"url": "https://ko-fi.com/interventionphp",
"type": "ko_fi"
}
],
"time": "2025-12-17T13:38:29+00:00"
},
{ {
"name": "laravel/fortify", "name": "laravel/fortify",
"version": "v1.34.1", "version": "v1.34.1",

View File

@ -7,7 +7,7 @@ return [
'title' => '안전하고 빠른 상품권 거래', 'title' => '안전하고 빠른 상품권 거래',
'desc' => '가장 저렴한 온라인 상품권 판매. 구글플레이·문화상품권·편의점 등 인기 상품을 할인 구매하세요.', 'desc' => '가장 저렴한 온라인 상품권 판매. 구글플레이·문화상품권·편의점 등 인기 상품을 할인 구매하세요.',
'cta_label' => '상품 보러가기', 'cta_label' => '상품 보러가기',
'cta_url' => '/shop', 'cta_url' => '/product/list',
'image' => [ 'image' => [
'default' => '/assets/images/common/hero/hero-01-shop.webp', 'default' => '/assets/images/common/hero/hero-01-shop.webp',
'mobile' => '/assets/images/common/hero/hero-01-shop.webp', 'mobile' => '/assets/images/common/hero/hero-01-shop.webp',
@ -19,7 +19,7 @@ return [
'title' => '카드/휴대폰 결제 지원', 'title' => '카드/휴대폰 결제 지원',
'desc' => '원하는 결제수단으로 편하게 결제하고, 발송은 빠르게 받아보세요.', 'desc' => '원하는 결제수단으로 편하게 결제하고, 발송은 빠르게 받아보세요.',
'cta_label' => '상품 보러가기', 'cta_label' => '상품 보러가기',
'cta_url' => '/shop', 'cta_url' => '/product/list',
'image' => [ 'image' => [
'default' => '/assets/images/common/hero/hero-02-pay.webp', 'default' => '/assets/images/common/hero/hero-02-pay.webp',
'mobile' => '/assets/images/common/hero/hero-02-pay.webp', 'mobile' => '/assets/images/common/hero/hero-02-pay.webp',

View File

@ -124,26 +124,38 @@ CREATE TABLE IF NOT EXISTS pfy_product_contents (
- 재고는 `stock_mode` 로 (연동판매는 `infinite`, 자사핀은 `limited`) - 재고는 `stock_mode` 로 (연동판매는 `infinite`, 자사핀은 `limited`)
```sql ```sql
CREATE TABLE IF NOT EXISTS pfy_product_skus ( CREATE TABLE `pfy_product_skus` (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT 'PK', `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT 'PK',
product_id BIGINT UNSIGNED NOT NULL COMMENT '상품 ID(pfy_products.id)', `product_id` bigint(20) unsigned NOT NULL COMMENT '상품 ID (pfy_products.id)',
denomination INT UNSIGNED NOT NULL COMMENT '권종 금액(예: 10000, 50000)',
normal_price INT UNSIGNED NOT NULL COMMENT '정상가(원)', `sku_code` varchar(60) DEFAULT NULL COMMENT '내부 SKU 코드(선택). 운영상 필요 시 사용',
discount_rate DECIMAL(5,2) NOT NULL DEFAULT 0.00 COMMENT '할인율(%)',
sale_price INT UNSIGNED NOT NULL COMMENT '판매가(원) - 운영/정산 안정 위해 계산 후 저장 권장', `face_value` int(10) unsigned NOT NULL COMMENT '권면가(원) 예: 10000',
stock_mode ENUM('infinite','limited') NOT NULL DEFAULT 'infinite' COMMENT '재고방식(무한/한정)', `normal_price` int(10) unsigned NOT NULL COMMENT '정상가(원)',
is_active TINYINT(1) NOT NULL DEFAULT 1 COMMENT '사용여부(1=사용,0=중지)', `discount_rate` decimal(5,2) NOT NULL DEFAULT 0.00 COMMENT '할인율(%) 예: 3.50',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '생성일', `sale_price` int(10) unsigned NOT NULL COMMENT '판매가(원) = floor(normal_price*(100-discount_rate)/100)',
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '수정일',
PRIMARY KEY (id), `status` enum('active','hidden') NOT NULL DEFAULT 'active' COMMENT '노출상태(active=노출, hidden=숨김)',
KEY idx_pfy_skus_product (product_id), `sort` int(10) unsigned NOT NULL DEFAULT 0 COMMENT '정렬(작을수록 우선)',
KEY idx_pfy_skus_active (product_id, is_active),
KEY idx_pfy_skus_denomination (product_id, denomination), `created_admin_id` bigint(20) unsigned DEFAULT NULL COMMENT '등록 관리자 ID',
CONSTRAINT fk_pfy_skus_product `updated_admin_id` bigint(20) unsigned DEFAULT NULL COMMENT '수정 관리자 ID',
FOREIGN KEY (product_id) REFERENCES pfy_products(id)
ON UPDATE CASCADE ON DELETE CASCADE `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '생성일시',
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci `updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '수정일시',
COMMENT='[PFY] 상품 SKU(권종/가격 단위)';
PRIMARY KEY (`id`),
UNIQUE KEY `uniq_product_face` (`product_id`, `face_value`),
KEY `idx_product` (`product_id`),
KEY `idx_status_sort` (`status`, `sort`, `id`)
/* FK를 쓰는 운영정책이면 아래 주석 해제 (기존 DB가 FK 거의 없으면 주석 유지 추천)
, CONSTRAINT `fk_pfy_skus_product`
FOREIGN KEY (`product_id`) REFERENCES `pfy_products` (`id`)
ON DELETE RESTRICT ON UPDATE CASCADE
*/
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='PFY 상품 SKU(금액권/가격)';
``` ```
--- ---

View File

@ -18,6 +18,8 @@
.lbtn:hover{background:rgba(255,255,255,.10);text-decoration:none;} .lbtn:hover{background:rgba(255,255,255,.10);text-decoration:none;}
.lbtn--ghost{background:transparent;} .lbtn--ghost{background:transparent;}
.lbtn--sm{padding:7px 10px;font-size:12px;border-radius:11px;} .lbtn--sm{padding:7px 10px;font-size:12px;border-radius:11px;}
.lbtn--primary{background:rgba(59,130,246,.88);border-color:rgba(59,130,246,.95);color:#fff;}
.lbtn--primary:hover{background:rgba(59,130,246,.98);}
.mono{padding:4px 8px;border-radius:10px;background:rgba(255,255,255,.06);border:1px solid rgba(255,255,255,.10); .mono{padding:4px 8px;border-radius:10px;background:rgba(255,255,255,.06);border:1px solid rgba(255,255,255,.10);
font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;font-size:12px;display:inline-block;} font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;font-size:12px;display:inline-block;}

View File

@ -18,6 +18,8 @@
border:1px solid rgba(255,255,255,.10);background:rgba(255,255,255,.06);color:inherit;cursor:pointer;} border:1px solid rgba(255,255,255,.10);background:rgba(255,255,255,.06);color:inherit;cursor:pointer;}
.lbtn:hover{background:rgba(255,255,255,.10);text-decoration:none;} .lbtn:hover{background:rgba(255,255,255,.10);text-decoration:none;}
.lbtn--ghost{background:transparent;} .lbtn--ghost{background:transparent;}
.lbtn--primary{background:rgba(59,130,246,.88);border-color:rgba(59,130,246,.95);color:#fff;}
.lbtn--primary:hover{background:rgba(59,130,246,.98);}
.mono{padding:4px 8px;border-radius:10px;background:rgba(255,255,255,.06);border:1px solid rgba(255,255,255,.10); .mono{padding:4px 8px;border-radius:10px;background:rgba(255,255,255,.06);border:1px solid rgba(255,255,255,.10);
font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;font-size:12px;display:inline-block;} font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;font-size:12px;display:inline-block;}

View File

@ -18,6 +18,8 @@
border:1px solid rgba(255,255,255,.10);background:rgba(255,255,255,.06);color:inherit;cursor:pointer;} border:1px solid rgba(255,255,255,.10);background:rgba(255,255,255,.06);color:inherit;cursor:pointer;}
.lbtn:hover{background:rgba(255,255,255,.10);text-decoration:none;} .lbtn:hover{background:rgba(255,255,255,.10);text-decoration:none;}
.lbtn--ghost{background:transparent;} .lbtn--ghost{background:transparent;}
.lbtn--primary{background:rgba(59,130,246,.88);border-color:rgba(59,130,246,.95);color:#fff;}
.lbtn--primary:hover{background:rgba(59,130,246,.98);}
.mono{padding:4px 8px;border-radius:10px;background:rgba(255,255,255,.06);border:1px solid rgba(255,255,255,.10); .mono{padding:4px 8px;border-radius:10px;background:rgba(255,255,255,.06);border:1px solid rgba(255,255,255,.10);
font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;font-size:12px;display:inline-block;} font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;font-size:12px;display:inline-block;}

View File

@ -48,31 +48,11 @@
[ [
'title' => '상품 관리', 'title' => '상품 관리',
'items' => [ 'items' => [
// 1) 기본 데이터(전시 구조) ['label' => '카테고리 관리', 'route' => 'admin.categories.index', 'roles' => ['super_admin','product']],
['label' => '카테고리 관리', 'route' => 'admin.categories.index', 'roles' => ['super_admin','product']], // ✅ 추천(신규) ['label' => '결제/매입/출금 수수료 관리', 'route' => 'admin.fees.index', 'roles' => ['super_admin','product']],
['label' => 'API 연동판매 코드 관리', 'route' => 'admin.sale-codes.index', 'roles' => ['super_admin','product']],
// 2) 상품 등록/관리 ['label' => '상품 이미지 라이브러리 관리', 'route' => 'admin.media.index', 'roles' => ['super_admin','product']],
['label' => '상품 리스트', 'route' => 'admin.products.index', 'roles' => ['super_admin','product']], ['label' => '판매 상품등록', 'route' => 'admin.products.index', 'roles' => ['super_admin','product']],
['label' => '상품 등록', 'route' => 'admin.products.create', 'roles' => ['super_admin','product']],
// 3) SKU/가격/권종 (상품 상세에서 같이 관리해도 되지만, 별도 메뉴가 있으면 운영 편함)
['label' => '금액권/가격 관리', 'route' => 'admin.skus.index', 'roles' => ['super_admin','product']], // ✅ 추천(신규)
// 4) 판매채널/연동코드 (DANAL/KORCULTURE/KPREPAID 코드 매핑)
['label' => '판매 코드 관리', 'route' => 'admin.sale-codes.index', 'roles' => ['super_admin','product']],
// 5) 자산(이미지) 관리
['label' => '이미지 라이브러리', 'route' => 'admin.media.index', 'roles' => ['super_admin','product']], // ✅ 추천(신규)
// 6) 자사 핀 재고/회수/추출
['label' => '핀 번호 관리', 'route' => 'admin.pins.index', 'roles' => ['super_admin','product']],
// ['label' => '핀 회수/추출', 'route' => 'admin.pins.recalls', 'roles' => ['super_admin','product']], // ✅ 핀 메뉴 내부 탭으로 처리해도 OK
// 7) 메인 노출/전시
['label' => '메인 노출 관리', 'route' => 'admin.exposure.index', 'roles' => ['super_admin','product']],
// 8) 결제 정책(상품쪽에서 설정하지만 성격상 “정책”이므로 아래쪽)
['label' => '결제 수수료/정책', 'route' => 'admin.fees.index', 'roles' => ['super_admin','product']],
], ],
], ],
[ [

View File

@ -0,0 +1,239 @@
@extends('admin.layouts.app')
@section('title', '카테고리 관리')
@section('page_title', '카테고리 관리')
@section('page_desc', '상품 1차/2차 카테고리를 설정합니다.')
@push('head')
<style>
.cat-grid { display: grid; grid-template-columns: 1fr 350px; gap: 20px; align-items: start; }
@media (max-width: 980px) { .cat-grid { grid-template-columns: 1fr; } }
.cat-list { background: rgba(255,255,255,.04); border: 1px solid rgba(255,255,255,.10); border-radius: 12px; }
.cat-item { padding: 10px 16px; border-bottom: 1px solid rgba(255,255,255,.05); display: flex; justify-content: space-between; align-items: center; transition: background 0.2s; }
.cat-group:last-child > .cat-item { border-bottom: none; }
.depth-2 { padding-left: 40px; background: rgba(0,0,0,.15); }
.cat-info { display: flex; align-items: center; gap: 10px; }
.cat-actions { display: flex; gap: 4px; }
.btn-sort { padding: 4px 8px; font-size: 11px; }
</style>
@endpush
@section('content')
<div class="cat-grid">
<div class="a-card" style="padding:16px;">
<div style="font-weight:900; font-size:16px; margin-bottom:16px;">카테고리 목록</div>
<div class="cat-list" id="cat-root">
@forelse($tree as $c1)
<div class="cat-group" data-id="{{ $c1['id'] }}">
<div class="cat-item">
<div class="cat-info">
<span class="pill {{ $c1['is_active'] ? 'pill--ok' : 'pill--muted' }}">
{{ $c1['is_active'] ? 'ON' : 'OFF' }}
</span>
<strong>{{ $c1['name'] }}</strong>
<span class="a-muted" style="font-size:12px;">({{ $c1['slug'] }})</span>
@if(!empty($c1['search_keywords']))
<div class="cat-tags" style="display: flex; gap: 4px; margin-left: 8px;">
@foreach(explode(' ', str_replace(',', ' ', $c1['search_keywords'])) as $tag)
@if(trim($tag))
<span style="font-size: 11px; color: #3b82f6; background: rgba(59, 130, 246, 0.1); padding: 2px 6px; border-radius: 4px;">
{{ str_starts_with($tag, '#') ? $tag : '#'.$tag }}
</span>
@endif
@endforeach
</div>
@endif
</div>
<div class="cat-actions" style="display: flex; align-items: center; gap: 4px;">
<button type="button" class="lbtn lbtn--ghost btn-sort" onclick="moveRow(this, -1, true)"></button>
<button type="button" class="lbtn lbtn--ghost btn-sort" onclick="moveRow(this, 1, true)"></button>
<button type="button" class="lbtn lbtn--sm lbtn--ghost" style="margin-left:8px;" onclick="editCat({{ json_encode($c1) }})">수정</button>
<form action="{{ route('admin.categories.destroy', $c1['id']) }}" method="POST" onsubmit="return confirm('정말 삭제하시겠습니까?\n하위 2차 카테고리가 있으면 삭제되지 않습니다.');" style="margin:0;">
@csrf @method('DELETE')
<button type="submit" class="lbtn lbtn--sm lbtn--danger">삭제</button>
</form>
</div>
</div>
<div class="cat-children">
@foreach($c1['children'] as $c2)
<div class="cat-item depth-2" data-id="{{ $c2['id'] }}">
<div class="cat-info">
<span class="pill {{ $c2['is_active'] ? 'pill--ok' : 'pill--muted' }}">
{{ $c2['is_active'] ? 'ON' : 'OFF' }}
</span>
<span>{{ $c2['name'] }}</span>
<span class="a-muted" style="font-size:12px;">({{ $c2['slug'] }})</span>
{{-- 추가: 등록된 해시태그 노출 --}}
@if(!empty($c2['search_keywords']))
<div class="cat-tags" style="display: flex; gap: 4px; margin-left: 8px;">
@foreach(explode(' ', str_replace(',', ' ', $c2['search_keywords'])) as $tag)
@if(trim($tag))
<span style="font-size: 10px; color: #10b981; background: rgba(16, 185, 129, 0.1); padding: 1px 5px; border-radius: 4px;">
{{ str_starts_with($tag, '#') ? $tag : '#'.$tag }}
</span>
@endif
@endforeach
</div>
@endif
</div>
<div class="cat-actions" style="display: flex; align-items: center; gap: 4px;">
<button type="button" class="lbtn lbtn--ghost btn-sort" onclick="moveRow(this, -1, false)"></button>
<button type="button" class="lbtn lbtn--ghost btn-sort" onclick="moveRow(this, 1, false)"></button>
<button type="button" class="lbtn lbtn--sm lbtn--ghost" style="margin-left:8px;" onclick="editCat({{ json_encode($c2) }})">수정</button>
<form action="{{ route('admin.categories.destroy', $c2['id']) }}" method="POST" onsubmit="return confirm('정말 삭제하시겠습니까?');" style="margin:0;">
@csrf @method('DELETE')
<button type="submit" class="lbtn lbtn--sm lbtn--danger">삭제</button>
</form>
</div>
</div>
@endforeach
</div>
</div>
@empty
<div style="padding: 20px; text-align: center;" class="a-muted">등록된 카테고리가 없습니다.</div>
@endforelse
</div>
</div>
<div class="a-card" style="padding:16px;">
<div style="font-weight:900; font-size:16px; margin-bottom:16px;" id="formTitle"> 카테고리 등록</div>
<form id="catForm" method="POST" action="{{ route('admin.categories.store') }}">
@csrf
<input type="hidden" name="_method" id="formMethod" value="POST">
<div style="display:grid; gap:12px;">
<div class="a-field">
<label class="a-label">상위 카테고리 (1 생성시 비워둠)</label>
<select class="a-input" name="parent_id" id="parentId">
<option value="">-- 최상위 (1 카테고리) --</option>
@foreach($tree as $c1)
<option value="{{ $c1['id'] }}">{{ $c1['name'] }}</option>
@endforeach
</select>
</div>
<div class="a-field">
<label class="a-label">카테고리명</label>
<input class="a-input" name="name" id="catName" required>
</div>
<div class="a-field">
<label class="a-label">URL 슬러그 (영문,숫자,하이픈) SEO 활용</label>
<input class="a-input" name="slug" id="catSlug" placeholder="예: google-play" required>
</div>
<div class="a-field">
<label class="a-label">검색 키워드 (해시태그 형태)</label>
<input class="a-input" name="search_keywords" id="catKeywords" placeholder="#컬쳐랜드 #문화상품권 #매입">
<div class="a-muted" style="font-size:11px; margin-top:4px;">#을 붙여서 입력해주세요. 사용자 검색 시 활용됩니다.</div>
</div>
<div class="a-field">
<label class="a-label">사용 여부</label>
<select class="a-input" name="is_active" id="catActive">
<option value="1">ON (사용)</option>
<option value="0">OFF (숨김)</option>
</select>
</div>
<div style="display:flex; gap:10px; margin-top:10px;">
<button type="button" class="lbtn lbtn--ghost" style="flex:1;" onclick="resetForm()">신규 등록으로 초기화</button>
<button type="submit" class="lbtn lbtn--primary" style="flex:1;">저장</button>
</div>
</div>
</form>
</div>
</div>
@endsection
@push('scripts')
<script>
const baseActionUrl = '{{ route('admin.categories.index') }}'; // 404 해결을 위한 Base URL 동적 할당
// 수정 폼 세팅
function editCat(cat) {
document.getElementById('formTitle').innerText = '카테고리 수정 (#'+cat.id+')';
// 올바른 라우트 주소로 조합
document.getElementById('catForm').action = baseActionUrl + '/' + cat.id;
document.getElementById('formMethod').value = 'PUT';
document.getElementById('parentId').value = cat.parent_id || '';
document.getElementById('catName').value = cat.name;
document.getElementById('catSlug').value = cat.slug;
document.getElementById('catKeywords').value = cat.search_keywords || '';
document.getElementById('catActive').value = cat.is_active;
// 1차 카테고리를 수정할 때는 부모를 자기 자신으로 못 바꾸게 방어
document.getElementById('parentId').disabled = (cat.parent_id === null);
}
// 폼 초기화
function resetForm() {
document.getElementById('formTitle').innerText = '새 카테고리 등록';
document.getElementById('catForm').action = '{{ route('admin.categories.store') }}';
document.getElementById('formMethod').value = 'POST';
document.getElementById('parentId').value = '';
document.getElementById('parentId').disabled = false;
document.getElementById('catName').value = '';
document.getElementById('catSlug').value = '';
document.getElementById('catKeywords').value = '';
document.getElementById('catActive').value = '1';
}
// 위/아래 이동 및 AJAX 호출
function moveRow(btn, direction, isRoot) {
// 1차면 .cat-group을 이동, 2차면 .cat-item을 이동
const item = isRoot ? btn.closest('.cat-group') : btn.closest('.cat-item');
const container = item.parentNode; // 부모 컨테이너 (.cat-list 또는 .cat-children)
if (direction === -1 && item.previousElementSibling) {
// 위로
container.insertBefore(item, item.previousElementSibling);
saveSort(container);
} else if (direction === 1 && item.nextElementSibling) {
// 아래로
container.insertBefore(item.nextElementSibling, item);
saveSort(container);
}
}
// 컨테이너 안의 요소들 순서를 배열로 뽑아 서버로 전송
function saveSort(container) {
// 컨테이너 바로 직계 자식들만 뽑아옴
const children = Array.from(container.children);
const ids = children.map(el => el.getAttribute('data-id'));
fetch('{{ route('admin.categories.sort') }}', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
'Accept': 'application/json'
},
body: JSON.stringify({ ids: ids })
})
.then(res => res.json())
.then(data => {
if(!data.ok) {
alert('순서 저장에 실패했습니다: ' + (data.message || ''));
} else {
// 성공 (시각적으로 방해되지 않도록 알림은 생략 가능)
console.log('순서 저장 완료', ids);
}
});
}
</script>
@endpush

View File

@ -0,0 +1,187 @@
@extends('admin.layouts.app')
@section('title', '결제/수수료 정책 관리')
@section('page_title', '결제/수수료 정책 관리')
@section('page_desc', '매입(출금) 기본 정책과 결제 수단별 PG 수수료를 관리합니다.')
@push('head')
<style>
.grid-layout { display: grid; grid-template-columns: 1fr 350px; gap: 20px; align-items: start; }
@media (max-width: 980px) { .grid-layout { grid-template-columns: 1fr; } }
.item-list { background: rgba(255,255,255,.04); border: 1px solid rgba(255,255,255,.10); border-radius: 12px; }
.item-row { padding: 12px 16px; border-bottom: 1px solid rgba(255,255,255,.05); display: flex; justify-content: space-between; align-items: center; transition: background 0.2s; }
.item-row:last-child { border-bottom: none; }
.item-info { display: flex; align-items: center; gap: 10px; }
.item-actions { display: flex; gap: 4px; align-items: center; }
.btn-sort { padding: 4px 8px; font-size: 11px; }
</style>
@endpush
@section('content')
<div class="a-card" style="padding:16px; margin-bottom:20px; border-top: 3px solid rgba(244,63,94,.88);">
<div style="font-weight:900; font-size:16px; margin-bottom:12px;">💸 매입(출금) 기본 수수료 정책</div>
<form method="POST" action="{{ route('admin.fees.buyback.update') }}" style="display:flex; gap:16px; align-items:flex-end; flex-wrap:wrap;">
@csrf @method('PUT')
<div class="a-field" style="flex:1; min-width:200px;">
<label class="a-label">고객 차감 매입 수수료율 (%)</label>
<input type="number" step="0.01" class="a-input" name="customer_fee_rate" value="{{ $buybackPolicy->customer_fee_rate ?? 15.00 }}">
</div>
<div class="a-field" style="flex:1; min-width:150px;">
<label class="a-label">자사 펌뱅킹 수수료 부과 방식</label>
<select class="a-input" name="bank_fee_type">
<option value="FLAT" {{ ($buybackPolicy->bank_fee_type ?? '') === 'FLAT' ? 'selected' : '' }}>정액 ()</option>
<option value="PERCENT" {{ ($buybackPolicy->bank_fee_type ?? '') === 'PERCENT' ? 'selected' : '' }}>정률 (%)</option>
</select>
</div>
<div class="a-field" style="flex:1; min-width:200px;">
<label class="a-label">자사 펌뱅킹 수수료 (/%)</label>
<input type="number" step="0.01" class="a-input" name="bank_fee_value" value="{{ $buybackPolicy->bank_fee_value ?? 500.00 }}">
</div>
<button type="submit" class="lbtn lbtn--danger" style="height:40px; margin-bottom:4px;">정책 저장</button>
</form>
</div>
<div class="grid-layout">
<div class="a-card" style="padding:16px;">
<div style="font-weight:900; font-size:16px; margin-bottom:16px;">💳 결제 수단 수수료 목록</div>
<div class="item-list" id="payment-root">
@forelse($paymentMethods as $pm)
<div class="item-row" data-id="{{ $pm['id'] }}">
<div class="item-info">
<span class="pill {{ $pm['is_active'] ? 'pill--ok' : 'pill--muted' }}">
{{ $pm['is_active'] ? 'ON' : 'OFF' }}
</span>
<strong>{{ $pm['name'] }}</strong>
<span class="pill pill--bad" style="font-size:11px;">노출명칭 : {{$pm['display_name'] }}</span>
<span class="pill pill--ok" style="font-size:12px;">CODE : {{ $pm['code'] }}</span>
<span class="pill pill--warn" style="font-size:11px;">고객: {{ (float)$pm['customer_fee_rate'] }}%</span>
<span class="pill pill--muted" style="font-size:11px;">PG원가: {{ (float)$pm['pg_fee_rate'] }}%</span>
</div>
<div class="item-actions">
<button type="button" class="lbtn lbtn--ghost btn-sort" onclick="moveRow(this, -1)"></button>
<button type="button" class="lbtn lbtn--ghost btn-sort" onclick="moveRow(this, 1)"></button>
<button type="button" class="lbtn lbtn--sm lbtn--ghost" style="margin-left:8px;" onclick="editPayment({{ json_encode($pm) }})">수정</button>
<form action="{{ route('admin.fees.payment.destroy', $pm['id']) }}" method="POST" onsubmit="return confirm('이 결제 수단을 삭제하시겠습니까?');" style="margin:0;">
@csrf @method('DELETE')
<button type="submit" class="lbtn lbtn--sm lbtn--danger">삭제</button>
</form>
</div>
</div>
@empty
<div style="padding: 20px; text-align: center;" class="a-muted">등록된 결제 수단이 없습니다.</div>
@endforelse
</div>
</div>
<div class="a-card" style="padding:16px;">
<div style="font-weight:900; font-size:16px; margin-bottom:16px;" id="formTitle"> 결제 수단 등록</div>
<form id="paymentForm" method="POST" action="{{ route('admin.fees.payment.store') }}">
@csrf
<input type="hidden" name="_method" id="formMethod" value="POST">
<div style="display:grid; gap:12px;">
<div class="a-field">
<label class="a-label">결제수단 코드 (연동 )</label>
<input class="a-input" name="code" id="pmCode" placeholder="예: MOBILE" required>
</div>
<div class="a-field">
<label class="a-label">관리자용 명칭</label>
<input class="a-input" name="name" id="pmName" placeholder="예: 다날 신용카드" required>
</div>
<div class="a-field">
<label class="a-label">사용자 노출 명칭</label>
<input class="a-input" name="display_name" id="pmDisplayName" placeholder="예: 신용카드결제" required>
</div>
<div class="a-field">
<label class="a-label">고객 부과 수수료율 (%)</label>
<input type="number" step="0.01" class="a-input" name="customer_fee_rate" id="pmCustomerFee" value="0.00" required>
</div>
<div class="a-field">
<label class="a-label">자사(PG) 원가 수수료율 (%)</label>
<input type="number" step="0.01" class="a-input" name="pg_fee_rate" id="pmPgFee" value="0.00" required>
</div>
<div class="a-field">
<label class="a-label">사용 여부</label>
<select class="a-input" name="is_active" id="pmActive">
<option value="1">ON (사용)</option>
<option value="0">OFF (숨김)</option>
</select>
</div>
<div style="display:flex; gap:10px; margin-top:10px;">
<button type="button" class="lbtn lbtn--ghost" style="flex:1;" onclick="resetForm()">신규 등록 초기화</button>
<button type="submit" class="lbtn lbtn--primary" style="flex:1;">저장</button>
</div>
</div>
</form>
</div>
</div>
@endsection
@push('scripts')
<script>
const baseActionUrl = '{{ route('admin.fees.index') }}';
function editPayment(pm) {
document.getElementById('formTitle').innerText = '결제 수단 수정 (#'+pm.id+')';
document.getElementById('paymentForm').action = baseActionUrl + '/payment/' + pm.id;
document.getElementById('formMethod').value = 'PUT';
document.getElementById('pmCode').value = pm.code;
document.getElementById('pmCode').readOnly = true; // 코드는 수정 불가
document.getElementById('pmName').value = pm.name;
document.getElementById('pmDisplayName').value = pm.display_name;
document.getElementById('pmCustomerFee').value = pm.customer_fee_rate;
document.getElementById('pmPgFee').value = pm.pg_fee_rate;
document.getElementById('pmActive').value = pm.is_active;
}
function resetForm() {
document.getElementById('formTitle').innerText = '새 결제 수단 등록';
document.getElementById('paymentForm').action = '{{ route('admin.fees.payment.store') }}';
document.getElementById('formMethod').value = 'POST';
document.getElementById('pmCode').value = '';
document.getElementById('pmCode').readOnly = false;
document.getElementById('pmName').value = '';
document.getElementById('pmDisplayName').value = '';
document.getElementById('pmCustomerFee').value = '0.00';
document.getElementById('pmPgFee').value = '0.00';
document.getElementById('pmActive').value = '1';
}
function moveRow(btn, direction) {
const item = btn.closest('.item-row');
const container = item.parentNode;
if (direction === -1 && item.previousElementSibling) {
container.insertBefore(item, item.previousElementSibling);
saveSort(container);
} else if (direction === 1 && item.nextElementSibling) {
container.insertBefore(item.nextElementSibling, item);
saveSort(container);
}
}
function saveSort(container) {
const ids = Array.from(container.children).map(el => el.getAttribute('data-id'));
fetch('{{ route('admin.fees.payment.sort') }}', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
'Accept': 'application/json'
},
body: JSON.stringify({ ids: ids })
});
}
</script>
@endpush

View File

@ -0,0 +1,191 @@
@extends('admin.layouts.app')
@section('title', '이미지 라이브러리')
@section('page_title', '이미지 라이브러리')
@section('page_desc', '상품 등록 시 사용할 이미지를 폴더별로 관리하고 이름을 지정합니다.')
@push('head')
<style>
.media-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 16px; margin-top: 20px; }
.media-item { background: rgba(255,255,255,.05); border: 1px solid rgba(255,255,255,.10); border-radius: 12px; overflow: hidden; position: relative; display: flex; flex-direction: column; transition: transform 0.2s, border-color 0.2s; }
.media-item:hover { transform: translateY(-2px); border-color: rgba(255,255,255,.3); }
.media-thumb { width: 100%; height: 150px; background-color: #000; display: flex; align-items: center; justify-content: center; overflow: hidden; border-bottom: 1px solid rgba(255,255,255,.05); }
.media-thumb img { width: 100%; height: 100%; object-fit: contain; }
.media-info { padding: 12px; font-size: 12px; flex: 1; display: flex; flex-direction: column; justify-content: space-between; }
.media-name { font-weight: bold; font-size: 13px; margin-bottom: 4px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; cursor: pointer; }
.media-name:hover { text-decoration: underline; color: #60a5fa; }
.media-meta { font-size: 11px; color: #9ca3af; margin-bottom: 10px; display: flex; gap: 6px; align-items: center; }
.action-row { display: flex; justify-content: space-between; align-items: center; border-top: 1px solid rgba(255,255,255,.1); padding-top: 8px; margin-top: auto; }
.action-btn { padding: 4px 8px; font-size: 11px; border-radius: 4px; border: 1px solid rgba(255,255,255,.2); background: transparent; color: #ccc; cursor: pointer; }
.action-btn:hover { background: rgba(255,255,255,.1); color: #fff; }
.action-btn.delete:hover { background: rgba(244,63,94,.2); border-color: rgba(244,63,94,.5); color: #f43f5e; }
</style>
@endpush
@section('content')
<div class="a-card" style="padding: 20px; border-top: 3px solid rgba(59,130,246,.8);">
<div style="font-weight:900; font-size:16px; margin-bottom:12px;">📤 이미지 업로드</div>
<form method="POST" action="{{ route('admin.media.store') }}" enctype="multipart/form-data">
@csrf
<div style="display:flex; gap:16px; align-items:flex-start; flex-wrap:wrap; margin-bottom: 16px;">
<div class="a-field" style="flex:1; min-width:150px; margin:0;">
<label class="a-label">저장 폴더 (영문/숫자)</label>
<input class="a-input" name="folder_name" placeholder="예: google, culture" value="default" required>
<div class="a-muted" style="font-size:11px; margin-top:4px;"> 상품 종류별로 폴더를 나누면 관리가 편합니다.</div>
</div>
<div class="a-field" style="flex:1; min-width:200px; margin:0;">
<label class="a-label">이미지 출력 이름 (선택)</label>
<input class="a-input" name="media_name" placeholder="예: 도서문화상품권 5만원권">
<div class="a-muted" style="font-size:11px; margin-top:4px;"> 입력 다중파일은 뒤에 _1, _2가 붙습니다. (비워두면 원본파일명)</div>
</div>
<div class="a-field" style="flex:2; min-width:300px; margin:0;">
<label class="a-label">이미지 파일 (다중 선택 가능)</label>
<input type="file" class="a-input" name="images[]" multiple accept="image/*" required style="padding: 6px;">
<div class="a-muted" style="font-size:11px; margin-top:4px;"> 최대 20 동시 업로드 가능 (장당 2MB 제한)</div>
</div>
<div style="margin-top: 24px;">
<button type="submit" class="lbtn lbtn--primary" style="height: 40px; padding: 0 24px;">업로드 실행</button>
</div>
</div>
</form>
</div>
<div class="a-card" style="padding: 20px; margin-top: 20px;">
<div style="display:flex; justify-content:space-between; align-items:center; flex-wrap:wrap; border-bottom:1px solid rgba(255,255,255,.1); padding-bottom:12px;">
<div style="font-weight:900; font-size:16px;">🖼️ 라이브러리 목록</div>
<form method="GET" action="{{ route('admin.media.index') }}" style="display:flex; gap:8px;">
<select class="a-input" name="folder" style="width:150px; height:50px; font-size:13px;" onchange="this.form.submit()">
<option value="">-- 전체 폴더 --</option>
@foreach($folders as $f)
<option value="{{ $f }}" {{ $currentFolder === $f ? 'selected' : '' }}>{{ $f }}</option>
@endforeach
</select>
@if($currentFolder)
<a href="{{ route('admin.media.index') }}" class="lbtn lbtn--ghost" style="height:50px; font-size:13px; padding:0 12px; line-height:34px;">초기화</a>
@endif
</form>
</div>
<div class="media-grid">
@forelse($page as $media)
<div class="media-item">
<div class="media-thumb">
<img src="{{ $media->file_path }}" alt="{{ $media->name ?? $media->original_name }}" loading="lazy">
</div>
<div class="media-info">
<div>
<div class="media-name"
id="media-name-{{ $media->id }}"
onclick="renameMedia({{ $media->id }}, '{{ addslashes($media->name ?? $media->original_name) }}')"
title="클릭하여 이름 변경">
{{ $media->name ?? $media->original_name }}
</div>
<div class="media-meta">
<span class="pill pill--muted" style="padding:2px 6px;">{{ $media->folder_name }}</span>
<span>{{ number_format($media->file_size / 1024, 1) }} KB</span>
</div>
<div class="a-muted" style="font-size:10px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;">
{{ $media->original_name }}
</div>
</div>
<div class="action-row">
<button type="button" class="action-btn" onclick="copyToClipboard('{{ $media->file_path }}')">경로복사</button>
<button type="button" class="action-btn" onclick="renameMedia({{ $media->id }}, '{{ addslashes($media->name ?? $media->original_name) }}')">이름변경</button>
<form action="{{ route('admin.media.destroy', $media->id) }}" method="POST" onsubmit="return confirm('이미지를 완전히 삭제하시겠습니까?\n이미 상품에 연결된 경우 엑스박스가 뜰 수 있습니다.');" style="margin:0;">
@csrf @method('DELETE')
<button type="submit" class="action-btn delete">삭제</button>
</form>
</div>
</div>
</div>
@empty
<div style="grid-column: 1 / -1; text-align: center; padding: 60px 0;">
<div class="a-muted" style="font-size:16px; margin-bottom:10px;">등록된 이미지가 없습니다.</div>
<div class="a-muted" style="font-size:13px;">상단 업로드 영역에서 이미지를 등록해주세요.</div>
</div>
@endforelse
</div>
<div style="margin-top:12px;">
{{ $page->onEachSide(1)->links('vendor.pagination.admin') }}
</div>
</div>
@endsection
@push('scripts')
<script>
// 1. 이미지 경로 복사 기능
function copyToClipboard(text) {
if (!navigator.clipboard) {
// HTTPS가 아닌 환경(로컬 개발 등) 대비 fallback
const ta = document.createElement("textarea");
ta.value = text;
document.body.appendChild(ta);
ta.select();
document.execCommand("copy");
document.body.removeChild(ta);
alert('이미지 경로가 복사되었습니다 (Fallback).\n' + text);
return;
}
navigator.clipboard.writeText(text).then(() => {
alert('이미지 경로가 클립보드에 복사되었습니다.\n' + text);
}).catch(err => {
console.error('복사 실패:', err);
alert('복사 실패! 브라우저 권한을 확인해주세요.');
});
}
// 2. 이미지 이름 변경 기능 (AJAX)
function renameMedia(id, currentName) {
const newName = prompt('이미지의 새로운 이름을 입력하세요:\n(상품 등록 시 검색에 사용됩니다)', currentName);
if (newName !== null && newName.trim() !== '' && newName !== currentName) {
// CSRF 토큰 가져오기
const token = document.querySelector('meta[name="csrf-token"]').content;
fetch(`{{ route('admin.media.index') }}/${id}/name`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': token,
'Accept': 'application/json'
},
body: JSON.stringify({ name: newName.trim() })
})
.then(res => res.json())
.then(data => {
if(data.ok) {
// 성공 시 DOM 즉시 업데이트 (새로고침 없이)
const nameEl = document.getElementById(`media-name-${id}`);
if(nameEl) {
nameEl.innerText = newName.trim();
// onclick 이벤트의 인자도 업데이트 (다음 번 변경을 위해)
// 주의: addslashes 처리가 복잡하므로 여기선 텍스트만 바꾸고,
// 연속 변경 시에는 data.ok 메시지만 띄워주는게 안전함.
}
// 간단한 토스트 알림 대용
// alert('이름이 변경되었습니다.');
} else {
alert('변경 실패: ' + (data.message || '오류가 발생했습니다.'));
}
})
.catch(err => {
console.error('AJAX Error:', err);
alert('서버 통신 중 오류가 발생했습니다.');
});
}
}
</script>
@endpush

View File

@ -0,0 +1,203 @@
@extends('admin.layouts.app')
@section('title', '핀(PIN) 재고 관리')
@section('page_title', '핀(PIN) 재고 관리')
@push('head')
<style>
.info-box { background: rgba(59,130,246,.1); border: 1px solid rgba(59,130,246,.3); padding: 16px; border-radius: 8px; margin-bottom: 20px; }
.info-box ul { margin: 8px 0 0 20px; padding: 0; color: #9ca3af; font-size: 13px; line-height: 1.6; }
.p-table { width: 100%; border-collapse: separate; border-spacing: 0; font-size: 13px; }
.p-table th { text-align: left; padding: 12px; border-bottom: 1px solid rgba(255,255,255,.1); color: #9ca3af; font-weight: 600; white-space: nowrap; }
.p-table td { padding: 12px; border-bottom: 1px solid rgba(255,255,255,.05); vertical-align: middle; }
.p-table tr:hover td { background: rgba(255,255,255,.02); }
.search-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 12px; align-items: end; margin-bottom: 20px; }
.pin-code-text { font-family: monospace; font-size: 14px; letter-spacing: 1px; color: #e5e7eb; background: rgba(0,0,0,.3); padding: 4px 8px; border-radius: 4px; }
/* 대시보드 스타일 */
.stat-grid { display: grid; grid-template-columns: repeat(5, 1fr); gap: 16px; margin-bottom: 24px; }
.stat-box { background: rgba(0,0,0,.2); border: 1px solid rgba(255,255,255,.1); padding: 16px; border-radius: 8px; text-align: center; }
.stat-label { font-size: 12px; color: #9ca3af; margin-bottom: 8px; }
.stat-value { font-size: 24px; font-weight: bold; }
</style>
@endpush
@section('content')
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px;">
<div>
<span style="color: #9ca3af; font-size: 13px;">상품명:</span> <strong style="color: #fff; font-size: 16px; margin-right: 12px;">{{ $product->name }}</strong>
<span style="color: #9ca3af; font-size: 13px;">권종명:</span> <strong style="color: #60a5fa; font-size: 16px;">{{ $sku->name }}</strong>
</div>
<a href="{{ route('admin.products.edit', $product->id) }}" class="lbtn lbtn--ghost"> 상품 수정으로 돌아가기</a>
</div>
<div class="stat-grid">
<div class="stat-box" style="border-top: 3px solid #6b7280;">
<div class="stat-label"> 누적 </div>
<div class="stat-value" style="color: #fff;">{{ number_format($stats['total']) }}</div>
</div>
<div class="stat-box" style="border-top: 3px solid #34d399;">
<div class="stat-label">판매 대기 (AVAILABLE)</div>
<div class="stat-value" style="color: #34d399;">{{ number_format($stats['AVAILABLE']) }}</div>
</div>
<div class="stat-box" style="border-top: 3px solid #60a5fa;">
<div class="stat-label">판매 완료 (SOLD)</div>
<div class="stat-value" style="color: #60a5fa;">{{ number_format($stats['SOLD']) }}</div>
</div>
<div class="stat-box" style="border-top: 3px solid #f87171;">
<div class="stat-label">회수됨 (RECALLED)</div>
<div class="stat-value" style="color: #f87171;">{{ number_format($stats['RECALLED']) }}</div>
</div>
<div class="stat-box" style="border-top: 3px solid #9ca3af;">
<div class="stat-label">기타 (결제중/만료)</div>
<div class="stat-value" style="color: #9ca3af;">{{ number_format($stats['HOLD'] + $stats['EXPIRED']) }}</div>
</div>
</div>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 24px; margin-bottom: 30px;">
<div class="a-card" style="padding: 24px; border-top: 3px solid #10b981;">
<h3 style="margin: 0 0 16px 0; font-size: 16px; color: #fff;"> 대량 등록 (붙여넣기)</h3>
<form action="{{ route('admin.pins.storeBulk', ['productId' => $product->id, 'skuId' => $sku->id]) }}" method="POST">
@csrf
<div class="a-field">
<textarea name="bulk_text" class="a-input" rows="5" placeholder="핀번호, 정액, 원가 (1줄 1개)&#10;ABCD-1234, 10000, 9000" required></textarea>
</div>
<div style="display: flex; gap: 10px; align-items: flex-end;">
<div class="a-field" style="margin: 0; flex: 1;">
<label class="a-label">일괄 유효기간 (선택)</label>
<input type="date" name="expiry_date" class="a-input">
</div>
<button type="submit" class="lbtn lbtn--primary" onclick="return confirm('등록하시겠습니까?');">등록 실행</button>
</div>
</form>
</div>
<div class="a-card" style="padding: 24px; border-top: 3px solid #f43f5e;">
<h3 style="margin: 0 0 16px 0; font-size: 16px; color: #fff;">📤 최신 회수 추출 (ZIP)</h3>
<div style="font-size: 12px; color: #9ca3af; margin-bottom: 12px;"> 등록된 역순(가장 최신 )으로 '판매 대기' 핀만 회수합니다.</div>
<form action="{{ route('admin.pins.recallBulk', ['productId' => $product->id, 'skuId' => $sku->id]) }}" method="POST">
@csrf
<div style="display: flex; gap: 10px; margin-bottom: 12px;">
<div class="a-field" style="margin:0; width: 150px;">
<label class="a-label">회수 수량</label>
<select name="amount_type" class="a-input" id="recall_amount_type" onchange="toggleCustomAmount()">
<option value="10">10 회수</option>
<option value="50">50 회수</option>
<option value="100">100 회수</option>
<option value="ALL">전체 회수 ({{ number_format($stats['AVAILABLE']) }})</option>
<option value="CUSTOM">직접 입력</option>
</select>
</div>
<div class="a-field" style="margin:0; width: 100px; display: none;" id="custom_amount_wrap">
<label class="a-label">직접 입력</label>
<input type="number" name="custom_amount" id="custom_amount_input" class="a-input" placeholder="수량">
</div>
</div>
<div style="display: flex; gap: 10px; align-items: flex-end;">
<div class="a-field" style="margin:0; flex: 1;">
<label class="a-label">압축 비밀번호 설정 (필수, 최소 4)</label>
<input type="text" name="zip_password" class="a-input" placeholder="비밀번호" required minlength="4">
</div>
<button type="submit" class="lbtn lbtn--danger" onclick="return confirm('⚠️ 선택한 수량의 핀이 회수(RECALLED) 처리되며, CSV 압축 파일로 다운로드됩니다.\n계속하시겠습니까?');">회수 다운로드</button>
</div>
</form>
</div>
</div>
<div class="a-card">
<div style="padding: 16px; border-bottom: 1px solid rgba(255,255,255,.05);">
<h3 style="margin: 0; font-size: 16px; color: #fff;">📋 재고 목록 검색</h3>
</div>
<div style="padding: 16px;">
<form method="GET" action="{{ route('admin.pins.index', ['productId' => $product->id, 'skuId' => $sku->id]) }}">
<div class="search-grid" style="grid-template-columns: 200px 300px auto;">
<div class="a-field" style="margin:0;">
<label class="a-label">상태 필터</label>
<select class="a-input" name="status" onchange="this.form.submit()">
<option value="">-- 전체 --</option>
<option value="AVAILABLE" {{ request('status') === 'AVAILABLE' ? 'selected' : '' }}>판매 대기</option>
<option value="SOLD" {{ request('status') === 'SOLD' ? 'selected' : '' }}>판매 완료</option>
<option value="RECALLED" {{ request('status') === 'RECALLED' ? 'selected' : '' }}>회수됨</option>
</select>
</div>
<div class="a-field" style="margin:0;">
<label class="a-label"> 번호 검색 (정확히 일치)</label>
<div style="display:flex; gap:8px;">
<input type="text" class="a-input" name="q" value="{{ request('q') }}" placeholder="전체 핀 번호 입력">
<button type="submit" class="lbtn lbtn--primary">검색</button>
</div>
</div>
<div>
@if(request('q') || request('status'))
<a href="{{ route('admin.pins.index', ['productId' => $product->id, 'skuId' => $sku->id]) }}" class="lbtn lbtn--ghost" style="margin-bottom: 2px;">초기화</a>
@endif
</div>
</div>
</form>
<div style="overflow-x: auto;">
<table class="p-table">
<thead>
<tr>
<th style="width: 60px; text-align: center;">ID</th>
<th>(PIN) 번호 (마스킹)</th>
<th style="text-align: right;">정액 금액</th>
<th style="text-align: right;">매입 원가</th>
<th style="text-align: right;">마진율</th>
<th style="text-align: center;">상태</th>
<th style="text-align: center;">등록일시</th>
</tr>
</thead>
<tbody>
@forelse($pins as $pin)
<tr>
<td style="text-align: center; color: #6b7280;">{{ $pin->id }}</td>
<td><span class="pin-code-text">{{ $pin->decrypted_code }}</span></td>
<td style="text-align: right; color: #fff;">{{ number_format($pin->face_value) }}</td>
<td style="text-align: right; color: #f87171;">{{ number_format($pin->buy_price) }}</td>
<td style="text-align: right; color: #34d399;">{{ $pin->margin_rate }}%</td>
<td style="text-align: center;">
@if($pin->status === 'AVAILABLE') <span class="pill pill--ok">판매대기</span>
@elseif($pin->status === 'SOLD') <span class="pill pill--muted">판매완료</span>
@elseif($pin->status === 'RECALLED') <span class="pill pill--danger">회수됨</span>
@else <span class="pill pill--warning">{{ $pin->status }}</span> @endif
</td>
<td style="text-align: center; color: #9ca3af;">{{ \Carbon\Carbon::parse($pin->created_at)->format('Y-m-d H:i') }}</td>
</tr>
@empty
<tr><td colspan="7" style="text-align: center; padding: 40px; color: #9ca3af;">등록된 번호가 없습니다.</td></tr>
@endforelse
</tbody>
</table>
</div>
<div style="margin-top:12px;">
{{ $pins->onEachSide(1)->links('vendor.pagination.admin') }}
</div>
</div>
</div>
@endsection
@push('scripts')
<script>
function toggleCustomAmount() {
const typeSelect = document.getElementById('recall_amount_type');
const customWrap = document.getElementById('custom_amount_wrap');
const customInput = document.getElementById('custom_amount_input');
if (typeSelect.value === 'CUSTOM') {
customWrap.style.display = 'block';
customInput.setAttribute('required', 'required');
} else {
customWrap.style.display = 'none';
customInput.removeAttribute('required');
}
}
</script>
@endpush

View File

@ -0,0 +1,560 @@
@extends('admin.layouts.app')
@section('title', '상품 등록')
@section('page_title', '상품 및 권종 등록')
@push('head')
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
<link href="https://cdn.jsdelivr.net/npm/summernote@0.8.18/dist/summernote-lite.min.css" rel="stylesheet">
<script src="https://cdn.jsdelivr.net/npm/summernote@0.8.18/dist/summernote-lite.min.js"></script>
<style>
.section-title { font-weight: 900; font-size: 16px; padding-bottom: 12px; border-bottom: 1px solid rgba(255,255,255,.1); margin-bottom: 16px; color: #fff; }
.sku-row { background: rgba(0,0,0,.15); border: 1px solid rgba(255,255,255,.1); padding: 16px; border-radius: 8px; margin-bottom: 12px; position: relative; }
.sku-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 12px; align-items: end; }
.btn-remove-sku { position: absolute; top: 10px; right: 10px; background: rgba(244,63,94,.1); color: #f43f5e; border: 1px solid rgba(244,63,94,.3); border-radius: 4px; padding: 4px 8px; font-size: 11px; cursor: pointer; z-index: 10; }
.btn-remove-sku:hover { background: rgba(244,63,94,.8); color: #fff; }
.modal-overlay { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,.8); display: none; align-items: center; justify-content: center; z-index: 9999; }
.modal-content { background: #1e1e2d; border: 1px solid rgba(255,255,255,.1); border-radius: 12px; width: 90%; max-width: 800px; max-height: 80vh; display: flex; flex-direction: column; }
.modal-header { padding: 16px; border-bottom: 1px solid rgba(255,255,255,.1); display: flex; justify-content: space-between; align-items: center; }
.modal-body { padding: 16px; overflow-y: auto; flex: 1; }
.media-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); gap: 12px; }
.media-item { border: 2px solid transparent; border-radius: 8px; overflow: hidden; cursor: pointer; background: #000; }
.media-item:hover { border-color: rgba(96,165,250,.5); }
.media-item img { width: 100%; height: 100px; object-fit: contain; }
.media-item-name { font-size: 11px; padding: 6px; text-align: center; background: rgba(255,255,255,.05); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.thumb-select-box { border: 2px dashed rgba(255,255,255,.2); width: 150px; height: 150px; border-radius: 12px; display: flex; align-items: center; justify-content: center; cursor: pointer; text-align: center; color: #9ca3af; overflow: hidden; position: relative; }
.thumb-select-box:hover { border-color: #60a5fa; color: #60a5fa; }
.thumb-select-box img { width: 100%; height: 100%; object-fit: contain; display: none; }
.check-group { display: flex; flex-wrap: wrap; gap: 16px; margin-top: 8px; }
.check-item { display: flex; align-items: center; gap: 6px; cursor: pointer; }
</style>
@endpush
@section('content')
@if ($errors->any())
<div class="a-card" style="background: rgba(244,63,94,.1); border: 1px solid rgba(244,63,94,.5); padding: 16px; margin-bottom: 24px;">
<strong style="color: #f43f5e; font-size: 15px;">⚠️ 저장 실패 (입력값을 확인해주세요)</strong>
<ul style="color: #fda4af; margin-top: 8px; margin-bottom: 0; padding-left: 20px; font-size: 13px;">
@foreach ($errors->all() as $error)
<li>{{ $error }}</li>
@endforeach
</ul>
</div>
@endif
<div style="display: flex; justify-content: flex-end; margin-bottom: 16px;">
<a href="{{ session('product_list_url', route('admin.products.index')) }}" class="lbtn lbtn--ghost"> 목록으로 돌아가기</a>
</div>
<form action="{{ route('admin.products.store') }}" method="POST">
@csrf
<div class="a-card" style="padding: 24px; margin-bottom: 24px; border-top: 3px solid #3b82f6;">
<div class="section-title">📦 1. 상품 기본 정보</div>
<div style="display: flex; gap: 24px;">
<div>
<label class="a-label">대표 썸네일</label>
<input type="hidden" name="thumbnail_media_id" id="thumbnail_media_id" value="{{ old('thumbnail_media_id') }}">
<div class="thumb-select-box" onclick="openMediaModal('thumbnail')">
<div id="thumb_placeholder">+<br>이미지 선택</div>
<img id="thumb_preview" src="" alt="preview">
</div>
</div>
<div style="flex:1; display: grid; grid-template-columns: 1fr 1fr; gap: 16px;">
<div class="a-field">
<label class="a-label">카테고리</label>
<select name="category_id" class="a-input" required>
<option value="">-- 선택 --</option>
@foreach($categories as $cate)
<option value="{{ $cate['id'] }}" {{ old('category_id') == $cate['id'] ? 'selected' : '' }}>{{ $cate['name'] }}</option>
@if(!empty($cate['children']))
@foreach($cate['children'] as $child)
<option value="{{ $child['id'] }}" {{ old('category_id') == $child['id'] ? 'selected' : '' }}>&nbsp;&nbsp; {{ $child['name'] }}</option>
@endforeach
@endif
@endforeach
</select>
</div>
<div class="a-field">
<label class="a-label">상품명</label>
<input type="text" name="name" class="a-input" value="{{ old('name') }}" placeholder="예: 구글 기프트코드" required>
</div>
<div class="a-field">
<label class="a-label">판매 상태</label>
<select name="status" class="a-input">
<option value="ACTIVE" {{ old('status') == 'ACTIVE' ? 'selected' : '' }}>판매중</option>
<option value="HIDDEN" {{ old('status', 'HIDDEN') == 'HIDDEN' ? 'selected' : '' }}>숨김</option>
<option value="SOLD_OUT" {{ old('status') == 'SOLD_OUT' ? 'selected' : '' }}>품절</option>
</select>
</div>
<div class="a-field">
<label class="a-label">상품 유형 / 매입 허용</label>
<div style="display:flex; gap:10px;">
<select name="product_type" class="a-input" style="flex:1;">
<option value="ONLINE" {{ old('product_type') == 'ONLINE' ? 'selected' : '' }}>온라인 발송</option>
<option value="DELIVERY" {{ old('product_type') == 'DELIVERY' ? 'selected' : '' }}>실물 배송</option>
</select>
<select name="is_buyback_allowed" class="a-input" style="flex:1;">
<option value="0" {{ old('is_buyback_allowed') == '0' ? 'selected' : '' }}>매입 불가</option>
<option value="1" {{ old('is_buyback_allowed') == '1' ? 'selected' : '' }}>매입 허용</option>
</select>
</div>
</div>
<div class="a-field" style="grid-column: span 2;">
<label class="a-label">결제 수단 허용 (다중 선택)</label>
<div class="check-group">
@foreach($payments as $pm)
<label class="check-item">
<input type="checkbox" name="payment_methods[]" value="{{ $pm->id }}" class="payment-checkbox" data-name="{{ $pm->name }}"
{{ in_array($pm->id, old('payment_methods', [])) || empty(old('payment_methods')) ? 'checked' : '' }}>
<span>{{ $pm->name }}</span>
</label>
@endforeach
</div>
</div>
<div class="a-field" style="grid-column: span 2;">
<label class="a-label">판매 기간 설정</label>
<div style="display:flex; gap:10px; align-items:center;">
<select name="is_always_on_sale" class="a-input" style="width:150px;" onchange="togglePeriod(this)">
<option value="1" {{ old('is_always_on_sale', '1') == '1' ? 'selected' : '' }}>상시 판매</option>
<option value="0" {{ old('is_always_on_sale') == '0' ? 'selected' : '' }}>기간 설정</option>
</select>
<div id="period-wrap" style="display:{{ old('is_always_on_sale', '1') == '0' ? 'flex' : 'none' }}; flex:1; gap:10px; align-items:center;">
<input type="datetime-local" name="sales_start_at" class="a-input" value="{{ old('sales_start_at') }}">
<span>~</span>
<input type="datetime-local" name="sales_end_at" class="a-input" value="{{ old('sales_end_at') }}">
</div>
</div>
</div>
</div>
</div>
</div>
<div class="a-card" style="padding: 24px; margin-bottom: 24px; border-top: 3px solid #10b981;">
<div class="section-title">🛒 2. 장바구니 구매 정책</div>
<div style="display: grid; grid-template-columns: 1fr 1fr 1fr 1fr; gap: 16px;">
<div class="a-field">
<label class="a-label">복합 구매 허용 방식</label>
<select name="purchase_type" class="a-input">
<option value="MULTI_SKU" {{ old('purchase_type') == 'MULTI_SKU' ? 'selected' : '' }}>여러 권종 여러 복합 구매</option>
<option value="MULTI_QTY" {{ old('purchase_type') == 'MULTI_QTY' ? 'selected' : '' }}>단일 권종 여러 구매</option>
<option value="SINGLE" {{ old('purchase_type') == 'SINGLE' ? 'selected' : '' }}>무조건 1장만 구매 가능</option>
</select>
</div>
<div class="a-field">
<label class="a-label">최소 구매 수량</label>
<input type="number" name="min_buy_qty" class="a-input" value="{{ old('min_buy_qty', 1) }}" min="1" required>
</div>
<div class="a-field">
<label class="a-label">최대 구매 수량 (0:무제한)</label>
<input type="number" name="max_buy_qty" class="a-input" value="{{ old('max_buy_qty', 0) }}" min="0" required>
</div>
<div class="a-field">
<label class="a-label">최대 결제 금액 (0:무제한)</label>
<input type="number" name="max_buy_amount" class="a-input" value="{{ old('max_buy_amount', 0) }}" min="0" step="1000" placeholder="예: 500000" required>
</div>
</div>
</div>
<div class="a-card" style="padding: 24px; margin-bottom: 24px; border-top: 3px solid #f59e0b;">
<div style="display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid rgba(255,255,255,.1); padding-bottom: 12px; margin-bottom: 16px;">
<div class="section-title" style="border:none; padding:0; margin:0;">🏷️ 3. 권종(옵션) 연동 설정</div>
<div id="btn-add-sku-wrap">
<button type="button" class="lbtn lbtn--info lbtn--sm" onclick="addSkuRow()">+ 권종 추가</button>
</div>
</div>
<div id="sku-container"></div>
</div>
<div class="a-card" style="padding: 24px; margin-bottom: 24px; border-top: 3px solid #8b5cf6;">
<div class="section-title">📝 4. 상세 설명 안내</div>
<div class="a-field">
<div style="display:flex; justify-content: space-between; margin-bottom: 8px;">
<label class="a-label" style="margin:0;">상품 상세 설명</label>
<button type="button" class="lbtn lbtn--sm lbtn--ghost" onclick="applyTemplate('description')"> /배너 기본 양식 적용</button>
</div>
<textarea name="description" id="editor-description" class="editor-target">{{ old('description') }}</textarea>
</div>
<div class="a-field">
<div style="display:flex; justify-content: space-between; margin-bottom: 8px;">
<label class="a-label" style="margin:0;">이용 안내</label>
<button type="button" class="lbtn lbtn--sm lbtn--ghost" onclick="applyTemplate('guide')"> 사용처/이용방법 양식 적용</button>
</div>
<textarea name="guide" id="editor-guide" class="editor-target">{{ old('guide') }}</textarea>
</div>
<div class="a-field">
<div style="display:flex; justify-content: space-between; margin-bottom: 8px;">
<label class="a-label" style="margin:0;">주의 사항 (환불 정책 )</label>
<button type="button" class="lbtn lbtn--sm lbtn--ghost" onclick="applyTemplate('warning')"> 공정위 환불 규정 적용</button>
</div>
<textarea name="warning" id="editor-warning" class="editor-target">{{ old('warning') }}</textarea>
</div>
</div>
<div style="text-align: center; margin-bottom: 50px;">
<button type="submit" class="lbtn lbtn--primary" style="padding: 0 40px; height: 48px; font-size: 16px;">상품 일괄 저장하기</button>
</div>
</form>
<div class="modal-overlay" id="media-modal">
<div class="modal-content">
<div class="modal-header">
<h3 style="margin:0; font-size:16px;">🖼️ 이미지 라이브러리 선택</h3>
<div style="display:flex; gap:10px; align-items:center;">
<select id="media-folder-select" class="a-input" style="width:200px; padding:4px; font-size:13px;" onchange="loadMedia(1)">
<option value="">-- 폴더를 선택하세요 --</option>
@foreach($folders as $folder)
<option value="{{ $folder }}">{{ $folder }}</option>
@endforeach
</select>
<button type="button" class="lbtn lbtn--ghost lbtn--sm" onclick="closeMediaModal()">닫기 X</button>
</div>
</div>
<div class="modal-body">
<div class="media-grid" id="media-modal-grid"></div>
<div style="display:flex; justify-content:center; gap:10px; margin-top:20px; align-items:center;">
<button type="button" class="lbtn lbtn--ghost" id="btn-media-prev" onclick="loadMedia(currentMediaPage - 1)">이전</button>
<span id="media-page-info" class="a-muted" style="font-size:13px;"></span>
<button type="button" class="lbtn lbtn--ghost" id="btn-media-next" onclick="loadMedia(currentMediaPage + 1)">다음</button>
</div>
</div>
</div>
</div>
<template id="sku-template">
<div class="sku-row">
<button type="button" class="btn-remove-sku" onclick="removeSkuRow(this)">X 삭제</button>
<div class="sku-grid">
<div class="a-field" style="margin:0;">
<label class="a-label">권종명</label>
<input type="text" name="skus[{idx}][name]" class="a-input" placeholder="예: 1만원권" required>
</div>
<div class="a-field" style="margin:0;">
<label class="a-label">과세/면세</label>
<select name="skus[{idx}][tax_type]" class="a-input">
<option value="TAX">과세</option>
<option value="FREE">면세</option>
</select>
</div>
<div class="a-field" style="margin:0;">
<label class="a-label">액면가 ()</label>
<input type="number" name="skus[{idx}][face_value]" class="a-input" value="0" min="0" required>
</div>
<div class="a-field" style="margin:0;">
<label class="a-label">할인 종류</label>
<select name="skus[{idx}][discount_type]" class="a-input" onchange="changeDiscountPlaceholder(this, {idx})">
<option value="PERCENT">정률 (%)</option>
<option value="FIXED">정액 ()</option>
</select>
</div>
<div class="a-field" style="margin:0;">
<label class="a-label" id="lbl-disc-{idx}">할인율 (%)</label>
<input type="number" name="skus[{idx}][discount_value]" id="input-disc-{idx}" class="a-input" value="0" min="0" required>
</div>
<div class="a-field" style="margin:0;">
<label class="a-label">판매 여부</label>
<select name="skus[{idx}][is_active]" class="a-input">
<option value="1">ON</option>
<option value="0">OFF</option>
</select>
</div>
</div>
<div class="sku-grid" style="margin-top:12px; border-top:1px dashed rgba(255,255,255,.1); padding-top:12px;">
<div class="a-field" style="margin:0;">
<label class="a-label">판매/연동 방식</label>
<select name="skus[{idx}][sales_method]" class="a-input" onchange="toggleApiFields(this, {idx})">
<option value="OWN_PIN">자사 핀코드 (재고차감)</option>
<option value="API_LINK">외부 API 연동 발급</option>
</select>
</div>
<div class="a-field api-fields" id="api-provider-{idx}" style="margin:0; display:none;">
<label class="a-label">API 연동사</label>
<select name="skus[{idx}][api_provider_id]" class="a-input" onchange="loadApiCodes(this, {idx})">
<option value="">선택</option>
@foreach($providers as $pv)
<option value="{{ $pv['id'] }}">{{ $pv['name'] }}</option>
@endforeach
</select>
</div>
<div class="a-field api-fields" id="api-code-select-wrap-{idx}" style="margin:0; display:none;">
<label class="a-label">연동 상품명 선택</label>
<select class="a-input" id="api-code-sel-{idx}" onchange="setCodeInput(this, {idx})">
<option value="">선택</option>
</select>
</div>
<div class="a-field api-fields" id="api-code-{idx}" style="margin:0; display:none;">
<label class="a-label">API 상품 코드 (자동입력)</label>
<input type="text" name="skus[{idx}][api_product_code]" id="api-code-input-{idx}" class="a-input" placeholder="코드" readonly>
</div>
</div>
</div>
</template>
@endsection
@push('scripts')
<script>
// --- 기간 설정 토글 ---
function togglePeriod(select) {
document.getElementById('period-wrap').style.display = select.value === '0' ? 'flex' : 'none';
}
// --- 동적 SKU 설정 ---
let skuIndex = 0;
const apiTree = @json($providers);
function addSkuRow() {
const template = document.getElementById('sku-template').innerHTML;
const html = template.replace(/{idx}/g, skuIndex);
document.getElementById('sku-container').insertAdjacentHTML('beforeend', html);
skuIndex++;
updateSkuUI();
}
// --- 권종 UI 동적 제어 (복합구매 시에만 노출/삭제 허용) ---
function updateSkuUI() {
const pType = document.querySelector('[name="purchase_type"]').value;
const addBtnWrap = document.getElementById('btn-add-sku-wrap');
const rows = document.querySelectorAll('.sku-row');
if (pType === 'MULTI_SKU') {
if (addBtnWrap) addBtnWrap.style.display = 'block';
rows.forEach(row => {
const btn = row.querySelector('.btn-remove-sku');
if (btn) btn.style.display = rows.length > 1 ? 'block' : 'none'; // 1개일 땐 삭제 숨김
});
} else {
if (addBtnWrap) addBtnWrap.style.display = 'none';
rows.forEach(row => {
const btn = row.querySelector('.btn-remove-sku');
if (btn) btn.style.display = 'none';
});
}
}
function removeSkuRow(btn) {
btn.closest('.sku-row').remove();
updateSkuUI();
}
document.querySelector('[name="purchase_type"]').addEventListener('change', updateSkuUI);
function changeDiscountPlaceholder(select, idx) {
const lbl = document.getElementById(`lbl-disc-${idx}`);
if(select.value === 'PERCENT') { lbl.innerText = '할인율 (%)'; }
else { lbl.innerText = '할인금액 (원)'; }
}
function toggleApiFields(selectElement, idx) {
const isApi = selectElement.value === 'API_LINK';
document.getElementById('api-provider-' + idx).style.display = isApi ? 'block' : 'none';
document.getElementById('api-code-select-wrap-' + idx).style.display = isApi ? 'block' : 'none';
document.getElementById('api-code-' + idx).style.display = isApi ? 'block' : 'none';
}
function loadApiCodes(selectProvider, idx) {
const providerId = selectProvider.value;
const codeSelect = document.getElementById(`api-code-sel-${idx}`);
const codeInput = document.getElementById(`api-code-input-${idx}`);
codeSelect.innerHTML = '<option value="">선택</option>';
codeInput.value = '';
if(providerId) {
const provider = apiTree.find(p => p.id == providerId);
if(provider && provider.children) {
provider.children.forEach(child => {
codeSelect.innerHTML += `<option value="${child.api_code}">${child.name} (${child.api_code})</option>`;
});
}
}
}
function setCodeInput(selectCode, idx) {
document.getElementById(`api-code-input-${idx}`).value = selectCode.value;
}
// --- 다날 신용카드 상호 배타적 선택 제어 ---
document.querySelectorAll('.payment-checkbox').forEach(chk => {
chk.addEventListener('change', function() {
if (this.checked && this.dataset.name.includes('다날') && this.dataset.name.includes('신용카드')) {
document.querySelectorAll('.payment-checkbox').forEach(other => {
if (other !== this && other.dataset.name.includes('다날') && other.dataset.name.includes('신용카드')) {
other.checked = false;
}
});
}
});
});
// --- 스마트 미디어 모달 로직 (에디터 & 썸네일 겸용) ---
let currentMediaPage = 1;
let mediaTargetType = 'thumbnail'; // 'thumbnail' 또는 'editor'
let currentEditorNode = null; // 어느 에디터에서 불렀는지 기억
function openMediaModal(target = 'thumbnail', editorNode = null) {
mediaTargetType = target;
currentEditorNode = editorNode;
document.getElementById('media-modal').style.display = 'flex';
loadMedia(1);
}
function closeMediaModal() {
document.getElementById('media-modal').style.display = 'none';
}
function loadMedia(page) {
if (page < 1) return;
const folder = document.getElementById('media-folder-select').value;
const grid = document.getElementById('media-modal-grid');
const pageInfo = document.getElementById('media-page-info');
const prevBtn = document.getElementById('btn-media-prev');
const nextBtn = document.getElementById('btn-media-next');
if (!folder) {
grid.innerHTML = '<div style="grid-column: 1 / -1; text-align: center; padding: 40px; color: #9ca3af;">상단에서 폴더를 먼저 선택해주세요.</div>';
pageInfo.innerText = '';
prevBtn.disabled = true;
nextBtn.disabled = true;
return;
}
fetch(`{{ route('admin.media.api.list') }}?page=${page}&folder=${folder}`, {
headers: {
'Accept': 'application/json',
'X-Requested-With': 'XMLHttpRequest'
}
})
.then(res => res.json())
.then(data => {
currentMediaPage = data.current_page;
grid.innerHTML = '';
if (data.data.length === 0) {
grid.innerHTML = '<div style="grid-column: 1 / -1; text-align: center; padding: 40px; color: #9ca3af;">이 폴더에는 이미지가 없습니다.</div>';
} else {
data.data.forEach(item => {
grid.innerHTML += `
<div class="media-item" onclick="selectMedia(${item.id}, '${item.file_path}')">
<img src="${item.file_path}" alt="">
<div class="media-item-name">${item.name || item.original_name}</div>
</div>
`;
});
}
pageInfo.innerText = `${data.current_page} / ${data.last_page}`;
prevBtn.disabled = data.current_page <= 1;
nextBtn.disabled = data.current_page >= data.last_page;
})
.catch(err => {
console.error(err);
alert('이미지를 불러오지 못했습니다.');
});
}
function selectMedia(id, path) {
if (mediaTargetType === 'thumbnail') {
document.getElementById('thumbnail_media_id').value = id;
document.getElementById('thumb_preview').src = path;
document.getElementById('thumb_preview').style.display = 'block';
document.getElementById('thumb_placeholder').style.display = 'none';
} else if (mediaTargetType === 'editor' && currentEditorNode) {
$(currentEditorNode).summernote('insertImage', path);
}
closeMediaModal();
}
// --- Summernote 에디터 초기화 ---
$(document).ready(function() {
var mediaLibraryButton = function (context) {
var ui = $.summernote.ui;
var button = ui.button({
contents: '<span style="font-weight:bold;">🖼️ 보관함 이미지 넣기</span>',
click: function () {
openMediaModal('editor', context.layoutInfo.note);
}
});
return button.render();
}
$('.editor-target').summernote({
height: 250,
lang: 'ko-KR',
toolbar: [
['style', ['style']],
['font', ['bold', 'underline', 'clear']],
['color', ['color']],
['para', ['ul', 'ol', 'paragraph']],
['table', ['table']],
['insert', ['link', 'mediaBtn', 'video']],
['view', ['codeview', 'help']]
],
buttons: {
mediaBtn: mediaLibraryButton
}
});
});
// --- 기본 템플릿(HTML) 불러오기 함수 ---
function applyTemplate(type) {
if(!confirm('기존에 작성된 내용이 있다면 덮어쓰여집니다. 적용하시겠습니까?')) return;
let html = '';
if (type === 'description') {
html = `
<div style="padding:20px; background:#f8fafc; border-radius:8px; text-align:center; margin-bottom:20px;">
<h3 style="color:#2563eb; margin-top:0;"> 언제 어디서나 편리하게 사용하는 기프트카드</h3>
<p style="color:#64748b;">(이곳에 상품의 매력적인 특징을 적어주세요)</p>
</div>
<table style="width:100%; border-collapse: collapse; text-align:center;">
<tr><th style="background:#f1f5f9; padding:10px; border:1px solid #e2e8f0; width:30%;">발행처</th><td style="padding:10px; border:1px solid #e2e8f0;">() 핀포유</td></tr>
<tr><th style="background:#f1f5f9; padding:10px; border:1px solid #e2e8f0;">유효기간</th><td style="padding:10px; border:1px solid #e2e8f0;">발행일로부터 5</td></tr>
<tr><th style="background:#f1f5f9; padding:10px; border:1px solid #e2e8f0;">사용처</th><td style="padding:10px; border:1px solid #e2e8f0;">전국 온라인/오프라인 가맹점</td></tr>
</table><p><br></p>
`;
$('#editor-description').summernote('code', html);
} else if (type === 'guide') {
html = `
<h4 style="border-left: 4px solid #10b981; padding-left: 8px;">온라인 사용방법</h4>
<ul><li>마이페이지 > 핀번호 확인 > 결제창에서 복사한 핀번호 입력</li></ul>
<h4 style="border-left: 4px solid #10b981; padding-left: 8px; margin-top:20px;">오프라인 사용방법</h4>
<ul><li>매장 카운터에서 바코드 제시 스캔하여 결제</li></ul><p><br></p>
`;
$('#editor-guide').summernote('code', html);
} else if (type === 'warning') {
html = `
<div style="border: 1px solid #fca5a5; padding: 15px; border-radius: 8px; background: #fef2f2;">
<strong style="color: #ef4444;">🚨 환불 취소 규정</strong>
<ul style="color: #7f1d1d; margin-bottom:0;">
<li>결제일로부터 7 이내에 미사용 핀에 한하여 전액 환불 가능합니다.</li>
<li>, 번호 조회(복호화) 진행한 경우 사용한 것으로 간주되어 환불이 불가합니다.</li>
<li>프로모션(할인) 상품은 부분 취소가 불가합니다.</li>
</ul>
</div><p><br></p>
`;
$('#editor-warning').summernote('code', html);
}
}
document.addEventListener("DOMContentLoaded", function() {
// 기존에 등록하려다 튕긴 입력값이 없다면 1개 자동 생성
if(document.querySelectorAll('.sku-row').length === 0) {
addSkuRow();
}
updateSkuUI(); // 로딩 시점에 UI 강제 업데이트
});
</script>
@endpush

View File

@ -0,0 +1,605 @@
@extends('admin.layouts.app')
@section('title', '상품 수정')
@section('page_title', '상품 및 권종 수정')
@push('head')
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
<link href="https://cdn.jsdelivr.net/npm/summernote@0.8.18/dist/summernote-lite.min.css" rel="stylesheet">
<script src="https://cdn.jsdelivr.net/npm/summernote@0.8.18/dist/summernote-lite.min.js"></script>
<style>
.section-title { font-weight: 900; font-size: 16px; padding-bottom: 12px; border-bottom: 1px solid rgba(255,255,255,.1); margin-bottom: 16px; color: #fff; }
.sku-row { background: rgba(0,0,0,.15); border: 1px solid rgba(255,255,255,.1); padding: 16px; border-radius: 8px; margin-bottom: 12px; position: relative; }
.sku-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 12px; align-items: end; }
.btn-remove-sku { position: absolute; top: 10px; right: 10px; background: rgba(244,63,94,.1); color: #f43f5e; border: 1px solid rgba(244,63,94,.3); border-radius: 4px; padding: 4px 8px; font-size: 11px; cursor: pointer; z-index: 10; }
.btn-remove-sku:hover { background: rgba(244,63,94,.8); color: #fff; }
.modal-overlay { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,.8); display: none; align-items: center; justify-content: center; z-index: 9999; }
.modal-content { background: #1e1e2d; border: 1px solid rgba(255,255,255,.1); border-radius: 12px; width: 90%; max-width: 800px; max-height: 80vh; display: flex; flex-direction: column; }
.modal-header { padding: 16px; border-bottom: 1px solid rgba(255,255,255,.1); display: flex; justify-content: space-between; align-items: center; }
.modal-body { padding: 16px; overflow-y: auto; flex: 1; }
.media-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); gap: 12px; }
.media-item { border: 2px solid transparent; border-radius: 8px; overflow: hidden; cursor: pointer; background: #000; }
.media-item:hover { border-color: rgba(96,165,250,.5); }
.media-item img { width: 100%; height: 100px; object-fit: contain; }
.media-item-name { font-size: 11px; padding: 6px; text-align: center; background: rgba(255,255,255,.05); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.thumb-select-box { border: 2px dashed rgba(255,255,255,.2); width: 150px; height: 150px; border-radius: 12px; display: flex; align-items: center; justify-content: center; cursor: pointer; text-align: center; color: #9ca3af; overflow: hidden; position: relative; }
.thumb-select-box:hover { border-color: #60a5fa; color: #60a5fa; }
.thumb-select-box img { width: 100%; height: 100%; object-fit: contain; }
.check-group { display: flex; flex-wrap: wrap; gap: 16px; margin-top: 8px; }
.check-item { display: flex; align-items: center; gap: 6px; cursor: pointer; }
</style>
@endpush
@section('content')
@if ($errors->any())
<div class="a-card" style="background: rgba(244,63,94,.1); border: 1px solid rgba(244,63,94,.5); padding: 16px; margin-bottom: 24px;">
<strong style="color: #f43f5e; font-size: 15px;">⚠️ 수정 실패 (입력값을 확인해주세요)</strong>
<ul style="color: #fda4af; margin-top: 8px; margin-bottom: 0; padding-left: 20px; font-size: 13px;">
@foreach ($errors->all() as $error)
<li>{{ $error }}</li>
@endforeach
</ul>
</div>
@endif
<div style="display: flex; justify-content: flex-end; margin-bottom: 16px;">
<a href="{{ session('product_list_url', route('admin.products.index')) }}" class="lbtn lbtn--ghost"> 목록으로 돌아가기</a>
</div>
<form action="{{ route('admin.products.update', $product->id) }}" method="POST">
@csrf
@method('PUT')
<div class="a-card" style="padding: 24px; margin-bottom: 24px; border-top: 3px solid #3b82f6;">
<div class="section-title">📦 1. 상품 기본 정보</div>
<div style="display: flex; gap: 24px;">
<div>
<label class="a-label">대표 썸네일</label>
<input type="hidden" name="thumbnail_media_id" id="thumbnail_media_id" value="{{ old('thumbnail_media_id', $product->thumbnail_media_id) }}">
<div class="thumb-select-box" onclick="openMediaModal('thumbnail')">
<div id="thumb_placeholder" style="display: {{ $thumbnail ? 'none' : 'block' }};">+<br>이미지 선택</div>
<img id="thumb_preview" src="{{ $thumbnail->file_path ?? '' }}" alt="preview" style="display: {{ $thumbnail ? 'block' : 'none' }};">
</div>
</div>
<div style="flex:1; display: grid; grid-template-columns: 1fr 1fr; gap: 16px;">
<div class="a-field">
<label class="a-label">카테고리</label>
<select name="category_id" class="a-input" required>
<option value="">-- 선택 --</option>
@foreach($categories as $cate)
<option value="{{ $cate['id'] }}" {{ old('category_id', $product->category_id) == $cate['id'] ? 'selected' : '' }}>{{ $cate['name'] }}</option>
@if(!empty($cate['children']))
@foreach($cate['children'] as $child)
<option value="{{ $child['id'] }}" {{ old('category_id', $product->category_id) == $child['id'] ? 'selected' : '' }}>&nbsp;&nbsp; {{ $child['name'] }}</option>
@endforeach
@endif
@endforeach
</select>
</div>
<div class="a-field">
<label class="a-label">상품명</label>
<input type="text" name="name" class="a-input" value="{{ old('name', $product->name) }}" required>
</div>
<div class="a-field">
<label class="a-label">판매 상태</label>
<select name="status" class="a-input">
<option value="ACTIVE" {{ old('status', $product->status) == 'ACTIVE' ? 'selected' : '' }}>판매중</option>
<option value="HIDDEN" {{ old('status', $product->status) == 'HIDDEN' ? 'selected' : '' }}>숨김</option>
<option value="SOLD_OUT" {{ old('status', $product->status) == 'SOLD_OUT' ? 'selected' : '' }}>품절</option>
</select>
</div>
<div class="a-field">
<label class="a-label">상품 유형 / 매입 허용</label>
<div style="display:flex; gap:10px;">
<select name="product_type" class="a-input" style="flex:1;">
<option value="ONLINE" {{ old('product_type', $product->product_type) == 'ONLINE' ? 'selected' : '' }}>온라인 발송</option>
<option value="DELIVERY" {{ old('product_type', $product->product_type) == 'DELIVERY' ? 'selected' : '' }}>실물 배송</option>
</select>
<select name="is_buyback_allowed" class="a-input" style="flex:1;">
<option value="0" {{ old('is_buyback_allowed', $product->is_buyback_allowed) == '0' ? 'selected' : '' }}>매입 불가</option>
<option value="1" {{ old('is_buyback_allowed', $product->is_buyback_allowed) == '1' ? 'selected' : '' }}>매입 허용</option>
</select>
</div>
</div>
@php
// JSON 결제 수단 배열화
$savedPayments = old('payment_methods', $product->allowed_payments ?? []);
@endphp
<div class="a-field" style="grid-column: span 2;">
<label class="a-label">결제 수단 허용 (다중 선택)</label>
<div class="check-group">
@foreach($payments as $pm)
<label class="check-item">
<input type="checkbox" name="payment_methods[]" value="{{ $pm->id }}" class="payment-checkbox" data-name="{{ $pm->name }}"
{{ in_array($pm->id, $savedPayments) ? 'checked' : '' }}>
<span>{{ $pm->name }}</span>
</label>
@endforeach
</div>
</div>
<div class="a-field" style="grid-column: span 2;">
<label class="a-label">판매 기간 설정</label>
<div style="display:flex; gap:10px; align-items:center;">
<select name="is_always_on_sale" class="a-input" style="width:150px;" onchange="togglePeriod(this)">
<option value="1" {{ old('is_always_on_sale', $product->is_always_on_sale) == '1' ? 'selected' : '' }}>상시 판매</option>
<option value="0" {{ old('is_always_on_sale', $product->is_always_on_sale) == '0' ? 'selected' : '' }}>기간 설정</option>
</select>
<div id="period-wrap" style="display:{{ old('is_always_on_sale', $product->is_always_on_sale) == '0' ? 'flex' : 'none' }}; flex:1; gap:10px; align-items:center;">
<input type="datetime-local" name="sales_start_at" class="a-input" value="{{ old('sales_start_at', $product->sales_start_at) }}">
<span>~</span>
<input type="datetime-local" name="sales_end_at" class="a-input" value="{{ old('sales_end_at', $product->sales_end_at) }}">
</div>
</div>
</div>
</div>
</div>
</div>
<div class="a-card" style="padding: 24px; margin-bottom: 24px; border-top: 3px solid #10b981;">
<div class="section-title">🛒 2. 장바구니 구매 정책</div>
<div style="display: grid; grid-template-columns: 1fr 1fr 1fr 1fr; gap: 16px;">
<div class="a-field">
<label class="a-label">복합 구매 허용 방식</label>
<select name="purchase_type" class="a-input">
<option value="MULTI_SKU" {{ old('purchase_type', $product->purchase_type) == 'MULTI_SKU' ? 'selected' : '' }}>여러 권종 여러 복합 구매</option>
<option value="MULTI_QTY" {{ old('purchase_type', $product->purchase_type) == 'MULTI_QTY' ? 'selected' : '' }}>단일 권종 여러 구매</option>
<option value="SINGLE" {{ old('purchase_type', $product->purchase_type) == 'SINGLE' ? 'selected' : '' }}>무조건 1장만 구매 가능</option>
</select>
</div>
<div class="a-field">
<label class="a-label">최소 구매 수량</label>
<input type="number" name="min_buy_qty" class="a-input" value="{{ old('min_buy_qty', $product->min_buy_qty) }}" min="1" required>
</div>
<div class="a-field">
<label class="a-label">최대 구매 수량 (0:무제한)</label>
<input type="number" name="max_buy_qty" class="a-input" value="{{ old('max_buy_qty', $product->max_buy_qty) }}" min="0" required>
</div>
<div class="a-field">
<label class="a-label">최대 결제 금액 (0:무제한)</label>
<input type="number" name="max_buy_amount" class="a-input" value="{{ old('max_buy_amount', $product->max_buy_amount) }}" min="0" step="1000" required>
</div>
</div>
</div>
<div class="a-card" style="padding: 24px; margin-bottom: 24px; border-top: 3px solid #f59e0b;">
<div style="display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid rgba(255,255,255,.1); padding-bottom: 12px; margin-bottom: 16px;">
<div class="section-title" style="border:none; padding:0; margin:0;">🏷️ 3. 권종(옵션) 연동 설정</div>
<div id="btn-add-sku-wrap">
<button type="button" class="lbtn lbtn--info lbtn--sm" onclick="addSkuRow()">+ 권종 추가</button>
</div>
</div>
<div id="sku-container"></div>
</div>
<div class="a-card" style="padding: 24px; margin-bottom: 24px; border-top: 3px solid #8b5cf6;">
<div class="section-title">📝 4. 상세 설명 안내</div>
<div class="a-field">
<div style="display:flex; justify-content: space-between; margin-bottom: 8px;">
<label class="a-label" style="margin:0;">상품 상세 설명</label>
<button type="button" class="lbtn lbtn--sm lbtn--ghost" onclick="applyTemplate('description')"> /배너 기본 양식 적용</button>
</div>
<textarea name="description" id="editor-description" class="editor-target">{{ old('description', $product->description ?? '') }}</textarea>
</div>
<div class="a-field">
<div style="display:flex; justify-content: space-between; margin-bottom: 8px;">
<label class="a-label" style="margin:0;">이용 안내</label>
<button type="button" class="lbtn lbtn--sm lbtn--ghost" onclick="applyTemplate('guide')"> 사용처/이용방법 양식 적용</button>
</div>
<textarea name="guide" id="editor-guide" class="editor-target">{{ old('guide', $product->guide ?? '') }}</textarea>
</div>
<div class="a-field">
<div style="display:flex; justify-content: space-between; margin-bottom: 8px;">
<label class="a-label" style="margin:0;">주의 사항 (환불 정책 )</label>
<button type="button" class="lbtn lbtn--sm lbtn--ghost" onclick="applyTemplate('warning')"> 공정위 환불 규정 적용</button>
</div>
<textarea name="warning" id="editor-warning" class="editor-target">{{ old('warning', $product->warning ?? '') }}</textarea>
</div>
</div>
<div style="text-align: center; margin-bottom: 50px;">
<button type="submit" class="lbtn lbtn--primary" style="padding: 0 40px; height: 48px; font-size: 16px;">수정 내용 저장하기</button>
</div>
</form>
<div class="modal-overlay" id="media-modal">
<div class="modal-content">
<div class="modal-header">
<h3 style="margin:0; font-size:16px;">🖼️ 이미지 라이브러리 선택</h3>
<div style="display:flex; gap:10px; align-items:center;">
<select id="media-folder-select" class="a-input" style="width:200px; padding:4px; font-size:13px;" onchange="loadMedia(1)">
<option value="">-- 폴더를 선택하세요 --</option>
@foreach($folders as $folder)
<option value="{{ $folder }}">{{ $folder }}</option>
@endforeach
</select>
<button type="button" class="lbtn lbtn--ghost lbtn--sm" onclick="closeMediaModal()">닫기 X</button>
</div>
</div>
<div class="modal-body">
<div class="media-grid" id="media-modal-grid"></div>
<div style="display:flex; justify-content:center; gap:10px; margin-top:20px; align-items:center;">
<button type="button" class="lbtn lbtn--ghost" id="btn-media-prev" onclick="loadMedia(currentMediaPage - 1)">이전</button>
<span id="media-page-info" class="a-muted" style="font-size:13px;"></span>
<button type="button" class="lbtn lbtn--ghost" id="btn-media-next" onclick="loadMedia(currentMediaPage + 1)">다음</button>
</div>
</div>
</div>
</div>
<template id="sku-template">
<div class="sku-row" id="sku-row-{idx}">
<button type="button" class="btn-remove-sku" onclick="removeSkuRow(this)">X 삭제</button>
<div class="sku-grid">
<div class="a-field" style="margin:0;">
<label class="a-label">권종명</label>
<input type="text" name="skus[{idx}][name]" class="a-input" required>
</div>
<div class="a-field" style="margin:0;">
<label class="a-label">과세/면세</label>
<select name="skus[{idx}][tax_type]" class="a-input">
<option value="TAX">과세</option>
<option value="FREE">면세</option>
</select>
</div>
<div class="a-field" style="margin:0;">
<label class="a-label">액면가 ()</label>
<input type="number" name="skus[{idx}][face_value]" class="a-input" required>
</div>
<div class="a-field" style="margin:0;">
<label class="a-label">할인 종류</label>
<select name="skus[{idx}][discount_type]" class="a-input" onchange="changeDiscountPlaceholder(this, {idx})">
<option value="PERCENT">정률 (%)</option>
<option value="FIXED">정액 ()</option>
</select>
</div>
<div class="a-field" style="margin:0;">
<label class="a-label" id="lbl-disc-{idx}">할인율 (%)</label>
<input type="number" name="skus[{idx}][discount_value]" class="a-input" required>
</div>
<div class="a-field" style="margin:0;">
<label class="a-label">판매 여부</label>
<select name="skus[{idx}][is_active]" class="a-input">
<option value="1">ON</option>
<option value="0">OFF</option>
</select>
</div>
</div>
<div class="sku-grid" style="margin-top:12px; border-top:1px dashed rgba(255,255,255,.1); padding-top:12px;">
<div class="a-field" style="margin:0;">
<label class="a-label">판매/연동 방식</label>
<select name="skus[{idx}][sales_method]" class="a-input" onchange="toggleApiFields(this, {idx})">
<option value="OWN_PIN">자사 핀코드 (재고차감)</option>
<option value="API_LINK">외부 API 연동 발급</option>
</select>
</div>
<div class="a-field api-fields" id="api-provider-{idx}" style="margin:0; display:none;">
<label class="a-label">API 연동사</label>
<select name="skus[{idx}][api_provider_id]" class="a-input" onchange="loadApiCodes(this, {idx})">
<option value="">선택</option>
@foreach($providers as $pv)
<option value="{{ $pv['id'] }}">{{ $pv['name'] }}</option>
@endforeach
</select>
</div>
<div class="a-field api-fields" id="api-code-select-wrap-{idx}" style="margin:0; display:none;">
<label class="a-label">연동 상품명 선택</label>
<select class="a-input" id="api-code-sel-{idx}" onchange="setCodeInput(this, {idx})">
<option value="">선택</option>
</select>
</div>
<div class="a-field api-fields" id="api-code-{idx}" style="margin:0; display:none;">
<label class="a-label">API 상품 코드</label>
<input type="text" name="skus[{idx}][api_product_code]" id="api-code-input-{idx}" class="a-input" readonly>
</div>
</div>
</div>
</template>
@endsection
@push('scripts')
<script>
function togglePeriod(select) {
document.getElementById('period-wrap').style.display = select.value === '0' ? 'flex' : 'none';
}
const apiTree = @json($providers);
let skuIndex = 0;
function addSkuRow(skuData = null) {
const template = document.getElementById('sku-template').innerHTML;
const html = template.replace(/{idx}/g, skuIndex);
document.getElementById('sku-container').insertAdjacentHTML('beforeend', html);
const row = document.getElementById('sku-row-' + skuIndex);
// skuData가 있다면 폼 데이터 채워넣기 (수정 모드)
if (skuData) {
if (skuData.id) {
// 기존 권종 식별용 Hidden ID
row.insertAdjacentHTML('beforeend', `<input type="hidden" name="skus[${skuIndex}][id]" value="${skuData.id}">`);
// ✨ 기존에 저장된 권종인 경우 우측 상단에 '핀 재고 관리' 버튼 노출
const pinUrl = `{{ url('products') }}/{{ $product->id }}/skus/${skuData.id}/pins`;
const pinBtnHtml = `<a href="${pinUrl}" class="lbtn lbtn--info lbtn--sm" style="position: absolute; top: 10px; right: 80px;" target="_self">🔑 핀 재고 관리</a>`;
row.insertAdjacentHTML('beforeend', pinBtnHtml);
}
if (skuData.name !== undefined) row.querySelector(`[name="skus[${skuIndex}][name]"]`).value = skuData.name;
if (skuData.tax_type !== undefined) row.querySelector(`[name="skus[${skuIndex}][tax_type]"]`).value = skuData.tax_type;
if (skuData.face_value !== undefined) row.querySelector(`[name="skus[${skuIndex}][face_value]"]`).value = skuData.face_value;
if (skuData.discount_type !== undefined) {
const dtSelect = row.querySelector(`[name="skus[${skuIndex}][discount_type]"]`);
dtSelect.value = skuData.discount_type;
changeDiscountPlaceholder(dtSelect, skuIndex);
}
if (skuData.discount_value !== undefined) row.querySelector(`[name="skus[${skuIndex}][discount_value]"]`).value = skuData.discount_value;
if (skuData.is_active !== undefined) row.querySelector(`[name="skus[${skuIndex}][is_active]"]`).value = skuData.is_active;
if (skuData.sales_method !== undefined) {
const smSelect = row.querySelector(`[name="skus[${skuIndex}][sales_method]"]`);
smSelect.value = skuData.sales_method;
toggleApiFields(smSelect, skuIndex);
}
if (skuData.api_provider_id) {
const providerSelect = row.querySelector(`[name="skus[${skuIndex}][api_provider_id]"]`);
providerSelect.value = skuData.api_provider_id;
loadApiCodes(providerSelect, skuIndex);
}
if (skuData.api_product_code) {
row.querySelector(`[name="skus[${skuIndex}][api_product_code]"]`).value = skuData.api_product_code;
setTimeout(() => {
const codeSelect = document.getElementById(`api-code-sel-${skuIndex-1}`);
if (codeSelect) codeSelect.value = skuData.api_product_code;
}, 50);
}
}
skuIndex++;
updateSkuUI();
}
// --- 권종 UI 동적 제어 (복합구매 시에만 노출/삭제 허용) ---
function updateSkuUI() {
const pType = document.querySelector('[name="purchase_type"]').value;
const addBtnWrap = document.getElementById('btn-add-sku-wrap');
const rows = document.querySelectorAll('.sku-row');
if (pType === 'MULTI_SKU') {
if (addBtnWrap) addBtnWrap.style.display = 'block';
rows.forEach(row => {
const btn = row.querySelector('.btn-remove-sku');
if (btn) btn.style.display = rows.length > 1 ? 'block' : 'none'; // 1개일 땐 삭제 숨김
});
} else {
if (addBtnWrap) addBtnWrap.style.display = 'none';
rows.forEach(row => {
const btn = row.querySelector('.btn-remove-sku');
if (btn) btn.style.display = 'none';
});
}
}
function removeSkuRow(btn) {
btn.closest('.sku-row').remove();
updateSkuUI();
}
document.querySelector('[name="purchase_type"]').addEventListener('change', updateSkuUI);
function changeDiscountPlaceholder(select, idx) {
const lbl = document.getElementById(`lbl-disc-${idx}`);
if(select.value === 'PERCENT') { lbl.innerText = '할인율 (%)'; } else { lbl.innerText = '할인금액 (원)'; }
}
function toggleApiFields(selectElement, idx) {
const isApi = selectElement.value === 'API_LINK';
document.getElementById('api-provider-' + idx).style.display = isApi ? 'block' : 'none';
document.getElementById('api-code-select-wrap-' + idx).style.display = isApi ? 'block' : 'none';
document.getElementById('api-code-' + idx).style.display = isApi ? 'block' : 'none';
}
function loadApiCodes(selectProvider, idx) {
const providerId = selectProvider.value;
const codeSelect = document.getElementById(`api-code-sel-${idx}`);
const codeInput = document.getElementById(`api-code-input-${idx}`);
codeSelect.innerHTML = '<option value="">선택</option>';
codeInput.value = '';
if(providerId) {
const provider = apiTree.find(p => p.id == providerId);
if(provider && provider.children) {
provider.children.forEach(child => {
codeSelect.innerHTML += `<option value="${child.api_code}">${child.name} (${child.api_code})</option>`;
});
}
}
}
function setCodeInput(selectCode, idx) { document.getElementById(`api-code-input-${idx}`).value = selectCode.value; }
// --- 다날 신용카드 상호 배타적 선택 제어 ---
document.querySelectorAll('.payment-checkbox').forEach(chk => {
chk.addEventListener('change', function() {
if (this.checked && this.dataset.name.includes('다날') && this.dataset.name.includes('신용카드')) {
document.querySelectorAll('.payment-checkbox').forEach(other => {
if (other !== this && other.dataset.name.includes('다날') && other.dataset.name.includes('신용카드')) {
other.checked = false;
}
});
}
});
});
// --- 스마트 미디어 모달 로직 (에디터 & 썸네일 겸용) ---
let currentMediaPage = 1;
let mediaTargetType = 'thumbnail'; // 'thumbnail' 또는 'editor'
let currentEditorNode = null; // 어느 에디터에서 불렀는지 기억
function openMediaModal(target = 'thumbnail', editorNode = null) {
mediaTargetType = target;
currentEditorNode = editorNode;
document.getElementById('media-modal').style.display = 'flex';
loadMedia(1);
}
function closeMediaModal() {
document.getElementById('media-modal').style.display = 'none';
}
function loadMedia(page) {
if (page < 1) return;
const folder = document.getElementById('media-folder-select').value;
const grid = document.getElementById('media-modal-grid');
const pageInfo = document.getElementById('media-page-info');
const prevBtn = document.getElementById('btn-media-prev');
const nextBtn = document.getElementById('btn-media-next');
if (!folder) {
grid.innerHTML = '<div style="grid-column: 1 / -1; text-align: center; padding: 40px; color: #9ca3af;">상단에서 폴더를 먼저 선택해주세요.</div>';
pageInfo.innerText = '';
prevBtn.disabled = true;
nextBtn.disabled = true;
return;
}
fetch(`{{ route('admin.media.api.list') }}?page=${page}&folder=${folder}`, {
headers: {
'Accept': 'application/json',
'X-Requested-With': 'XMLHttpRequest'
}
})
.then(res => res.json())
.then(data => {
currentMediaPage = data.current_page;
grid.innerHTML = '';
if (data.data.length === 0) {
grid.innerHTML = '<div style="grid-column: 1 / -1; text-align: center; padding: 40px; color: #9ca3af;">이 폴더에는 이미지가 없습니다.</div>';
} else {
data.data.forEach(item => {
grid.innerHTML += `
<div class="media-item" onclick="selectMedia(${item.id}, '${item.file_path}')">
<img src="${item.file_path}" alt="">
<div class="media-item-name">${item.name || item.original_name}</div>
</div>
`;
});
}
pageInfo.innerText = `${data.current_page} / ${data.last_page}`;
prevBtn.disabled = data.current_page <= 1;
nextBtn.disabled = data.current_page >= data.last_page;
})
.catch(err => {
console.error(err);
alert('이미지를 불러오지 못했습니다.');
});
}
function selectMedia(id, path) {
if (mediaTargetType === 'thumbnail') {
document.getElementById('thumbnail_media_id').value = id;
document.getElementById('thumb_preview').src = path;
document.getElementById('thumb_preview').style.display = 'block';
document.getElementById('thumb_placeholder').style.display = 'none';
} else if (mediaTargetType === 'editor' && currentEditorNode) {
$(currentEditorNode).summernote('insertImage', path);
}
closeMediaModal();
}
// --- Summernote 에디터 초기화 ---
$(document).ready(function() {
var mediaLibraryButton = function (context) {
var ui = $.summernote.ui;
var button = ui.button({
contents: '<span style="font-weight:bold;">🖼️ 보관함 이미지 넣기</span>',
click: function () {
openMediaModal('editor', context.layoutInfo.note);
}
});
return button.render();
}
$('.editor-target').summernote({
height: 250,
lang: 'ko-KR',
toolbar: [
['style', ['style']],
['font', ['bold', 'underline', 'clear']],
['color', ['color']],
['para', ['ul', 'ol', 'paragraph']],
['table', ['table']],
['insert', ['link', 'mediaBtn', 'video']],
['view', ['codeview', 'help']]
],
buttons: {
mediaBtn: mediaLibraryButton
}
});
});
// --- 기본 템플릿(HTML) 불러오기 함수 ---
function applyTemplate(type) {
if(!confirm('기존에 작성된 내용이 있다면 덮어쓰여집니다. 적용하시겠습니까?')) return;
let html = '';
if (type === 'description') {
html = `
<div style="padding:20px; background:#f8fafc; border-radius:8px; text-align:center; margin-bottom:20px;">
<h3 style="color:#2563eb; margin-top:0;"> 언제 어디서나 편리하게 사용하는 기프트카드</h3>
<p style="color:#64748b;">(이곳에 상품의 내용을 작성하세요)</p>
</div>
<table style="width:100%; border-collapse: collapse; text-align:center;">
<tr><th style="background:#f1f5f9; padding:10px; border:1px solid #e2e8f0; width:30%;">발행처</th><td style="padding:10px; border:1px solid #e2e8f0;">() 핀포유</td></tr>
<tr><th style="background:#f1f5f9; padding:10px; border:1px solid #e2e8f0;">유효기간</th><td style="padding:10px; border:1px solid #e2e8f0;">발행일로부터 5</td></tr>
<tr><th style="background:#f1f5f9; padding:10px; border:1px solid #e2e8f0;">사용처</th><td style="padding:10px; border:1px solid #e2e8f0;">전국 온라인/오프라인 가맹점</td></tr>
</table><p><br></p>
`;
$('#editor-description').summernote('code', html);
} else if (type === 'guide') {
html = `
<h4 style="border-left: 4px solid #10b981; padding-left: 8px;">온라인 사용방법</h4>
<ul><li>마이페이지 > 핀번호 확인 > 결제창에서 복사한 핀번호 입력</li></ul>
<h4 style="border-left: 4px solid #10b981; padding-left: 8px; margin-top:20px;">오프라인 사용방법</h4>
<ul><li>매장 카운터에서 바코드 제시 스캔하여 결제</li></ul><p><br></p>
`;
$('#editor-guide').summernote('code', html);
} else if (type === 'warning') {
html = `
<div style="border: 1px solid #fca5a5; padding: 15px; border-radius: 8px; background: #fef2f2;">
<strong style="color: #ef4444;">🚨 환불 취소 규정</strong>
<ul style="color: #7f1d1d; margin-bottom:0;">
<li>결제일로부터 7 이내에 미사용 핀에 한하여 전액 환불 가능합니다.</li>
<li>, 번호 조회(복호화) 진행한 경우 사용한 것으로 간주되어 환불이 불가합니다.</li>
<li>프로모션(할인) 상품은 부분 취소가 불가합니다.</li>
</ul>
</div><p><br></p>
`;
$('#editor-warning').summernote('code', html);
}
}
document.addEventListener("DOMContentLoaded", function() {
let rawSkus = @json(old('skus', $skus ?? []));
let skus = Array.isArray(rawSkus) ? rawSkus : Object.values(rawSkus);
if(skus.length > 0) {
skus.forEach(sku => addSkuRow(sku));
} else {
addSkuRow();
}
updateSkuUI(); // 로딩 시점에 UI 강제 업데이트
});
</script>
@endpush

View File

@ -0,0 +1,158 @@
@extends('admin.layouts.app')
@section('title', '상품 관리')
@section('page_title', '상품 목록')
@section('page_desc', '등록된 상품(권종 포함)을 조회하고 관리합니다.')
@push('head')
<style>
.search-box { background: rgba(255,255,255,.03); padding: 20px; border-radius: 12px; border: 1px solid rgba(255,255,255,.1); margin-bottom: 20px; }
.search-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 12px; align-items: end; }
.p-table { width: 100%; border-collapse: separate; border-spacing: 0; font-size: 13px; }
.p-table th { text-align: left; padding: 12px; border-bottom: 1px solid rgba(255,255,255,.1); color: #9ca3af; font-weight: 600; white-space: nowrap; }
.p-table td { padding: 12px; border-bottom: 1px solid rgba(255,255,255,.05); vertical-align: middle; }
.p-table tr:hover td { background: rgba(255,255,255,.02); }
.p-thumb { width: 50px; height: 50px; border-radius: 6px; background: #000; overflow: hidden; border: 1px solid rgba(255,255,255,.1); display: flex; align-items: center; justify-content: center; }
.p-thumb img { width: 100%; height: 100%; object-fit: cover; }
.p-thumb.no-img { color: #555; font-size: 10px; text-align: center; line-height: 1.2; padding: 4px; }
.p-name { font-weight: bold; font-size: 14px; margin-bottom: 4px; display: block; color: #fff; text-decoration: none; }
.p-name:hover { text-decoration: underline; color: #60a5fa; }
</style>
@endpush
@section('content')
<div class="search-box">
<form method="GET" action="{{ route('admin.products.index') }}">
<div class="search-grid">
<div class="a-field" style="margin:0;">
<label class="a-label">카테고리</label>
<select class="a-input" name="cate">
<option value="">-- 전체 카테고리 --</option>
@foreach($categories as $cate)
<option value="{{ $cate['id'] }}" {{ request('cate') == $cate['id'] ? 'selected' : '' }}>{{ $cate['name'] }}</option>
@if(!empty($cate['children']))
@foreach($cate['children'] as $child)
<option value="{{ $child['id'] }}" {{ request('cate') == $child['id'] ? 'selected' : '' }}>&nbsp;&nbsp; {{ $child['name'] }}</option>
@endforeach
@endif
@endforeach
</select>
</div>
<div class="a-field" style="margin:0;">
<label class="a-label">상태</label>
<select class="a-input" name="status">
<option value="">-- 전체 --</option>
<option value="ACTIVE" {{ request('status') === 'ACTIVE' ? 'selected' : '' }}>판매중</option>
<option value="HIDDEN" {{ request('status') === 'HIDDEN' ? 'selected' : '' }}>숨김</option>
<option value="SOLD_OUT" {{ request('status') === 'SOLD_OUT' ? 'selected' : '' }}>품절</option>
</select>
</div>
<div class="a-field" style="margin:0; grid-column: span 2;">
<label class="a-label">검색어 (상품명)</label>
<div style="display:flex; gap:10px;">
<input type="text" class="a-input" name="q" value="{{ request('q') }}" placeholder="상품명을 입력하세요">
<button type="submit" class="lbtn lbtn--primary" style="width:100px;">검색</button>
</div>
</div>
</div>
</form>
</div>
<div class="a-card">
<div style="padding: 16px; border-bottom: 1px solid rgba(255,255,255,.05); display: flex; justify-content: space-between; align-items: center;">
<div style="font-size: 14px;"> <strong style="color: #fff;">{{ number_format($products->total()) }}</strong>개의 상품</div>
<a href="{{ route('admin.products.create') }}" class="lbtn lbtn--primary">+ 상품 등록</a>
</div>
<div style="overflow-x: auto;">
<table class="p-table">
<thead>
<tr>
<th style="width: 60px; text-align: center;">ID</th>
<th style="width: 70px;">이미지</th>
<th style="width: 250px;">카테고리 / 상품명</th>
<th>권종 상세 (API / 재고)</th>
<th style="text-align: center; width: 140px;">판매 기간 / 매입</th>
<th style="width: 80px; text-align: center;">상태</th>
<th style="width: 80px; text-align: center;">관리</th>
</tr>
</thead>
<tbody>
@forelse($products as $p)
<tr>
<td style="text-align: center; color: #6b7280;">{{ $p->id }}</td>
<td>
<div class="p-thumb {{ !$p->thumbnail_path ? 'no-img' : '' }}">
@if($p->thumbnail_path)
<img src="{{ $p->thumbnail_path }}" alt="thumb">
@else
<span>NO<br>IMAGE</span>
@endif
</div>
</td>
<td>
<div class="a-muted" style="font-size: 11px; margin-bottom: 2px;">{{ $p->category_path ?? '카테고리 없음' }}</div>
<a href="{{ route('admin.products.edit', $p->id) }}" class="p-name">{{ $p->name }}</a>
<div style="font-size: 11px; color: #9ca3af; margin-top: 4px;">
@if($p->purchase_type === 'MULTI_SKU') <span style="color:#34d399;">[복합구매]</span>
@elseif($p->purchase_type === 'MULTI_QTY') <span style="color:#60a5fa;">[단일 다수]</span>
@else <span style="color:#fbbf24;">[1 한정]</span> @endif
</div>
</td>
<td style="font-size: 12px; line-height: 1.6;">
@foreach($p->skus as $sku)
<div style="margin-bottom: 6px; padding-bottom: 6px; border-bottom: 1px dashed rgba(255,255,255,0.05);">
<strong style="color: #fff;">{{ number_format($sku->face_value) }}</strong>
@if($sku->discount_value > 0)
<span style="color: #f472b6;">
( {{ $sku->discount_type === 'PERCENT' ? $sku->discount_value.'%' : number_format($sku->discount_value).'원' }})
</span>
@endif
<span style="color: #9ca3af; margin: 0 4px;">| {{ $sku->tax_type === 'TAX' ? '과세' : '면세' }} |</span>
@if($sku->sales_method === 'API_LINK')
<span style="color: #60a5fa;">API: {{ $sku->provider_name ?? '연동사' }} {{ $sku->api_product_code ? '('.$sku->api_product_code.')' : '' }}</span>
@else
<span style="color: #34d399;">자사핀 재고: {{ number_format($sku->available_pins) }} / {{ number_format($sku->total_pins) }}</span>
@endif
</div>
@endforeach
</td>
<td style="text-align: center; font-size: 12px;">
@if($p->is_always_on_sale)
<div style="color: #34d399; margin-bottom: 4px;">상시판매</div>
@else
<div style="color: #f59e0b; margin-bottom: 4px;">{{ \Carbon\Carbon::parse($p->sales_end_at)->format('Y-m-d H시 마감') }}</div>
@endif
@if($p->is_buyback_allowed)
<span class="pill pill--outline pill--info" style="font-size: 10px;">매입가능</span>
@else
<span class="pill pill--outline pill--muted" style="font-size: 10px;">매입불가</span>
@endif
</td>
<td style="text-align: center;">
@if($p->status === 'ACTIVE') <span class="pill pill--ok">판매중</span>
@elseif($p->status === 'SOLD_OUT') <span class="pill pill--danger">품절</span>
@else <span class="pill pill--muted">숨김</span> @endif
</td>
<td style="text-align: center;">
<a href="{{ route('admin.products.edit', $p->id) }}" class="lbtn lbtn--sm lbtn--ghost">수정</a>
</td>
</tr>
@empty
<tr><td colspan="7" style="text-align: center; padding: 40px;">등록된 상품이 없습니다.</td></tr>
@endforelse
</tbody>
</table>
</div>
<div style="margin-top:12px;">
{{ $products->onEachSide(1)->links('vendor.pagination.admin') }}
</div>
</div>
@endsection

View File

@ -0,0 +1,230 @@
@extends('admin.layouts.app')
@section('title', '판매 코드 관리')
@section('page_title', 'API 연동 판매 코드 관리')
@section('page_desc', '외부 연동사(다날 등)와 해당 업체의 고유 상품 코드를 매핑합니다.')
@push('head')
<style>
.grid-layout { display: grid; grid-template-columns: 1fr 350px; gap: 20px; align-items: start; }
@media (max-width: 980px) { .grid-layout { grid-template-columns: 1fr; } }
.cat-list { background: rgba(255,255,255,.04); border: 1px solid rgba(255,255,255,.10); border-radius: 12px; }
.cat-item { padding: 10px 16px; border-bottom: 1px solid rgba(255,255,255,.05); display: flex; justify-content: space-between; align-items: center; transition: background 0.2s; }
.cat-group:last-child > .cat-item, .cat-children > .cat-item:last-child { border-bottom: none; }
.depth-2 { padding-left: 40px; background: rgba(0,0,0,.15); }
.cat-info { display: flex; align-items: center; gap: 10px; }
.cat-actions { display: flex; align-items: center; gap: 4px; }
.btn-sort { padding: 4px 8px; font-size: 11px; }
</style>
@endpush
@section('content')
<div class="grid-layout">
<div class="a-card" style="padding:16px;">
<div style="font-weight:900; font-size:16px; margin-bottom:16px;">🏢 연동사 📦 상품코드 목록</div>
<div class="cat-list">
@forelse($tree as $pv)
<div class="cat-group">
<div class="cat-item" style="background: rgba(59,130,246,.1);">
<div class="cat-info">
<span class="pill {{ $pv['is_active'] ? 'pill--ok' : 'pill--muted' }}">
{{ $pv['is_active'] ? 'ON' : 'OFF' }}
</span>
<strong style="color:#60a5fa;">{{ $pv['name'] }}</strong>
<span class="mono">[{{ $pv['code'] }}]</span>
</div>
<div class="cat-actions">
<button type="button" class="lbtn lbtn--sm lbtn--ghost" onclick="editProvider({{ json_encode($pv) }})">연동사 수정</button>
<form action="{{ route('admin.sale-codes.provider.destroy', $pv['id']) }}" method="POST" onsubmit="return confirm('이 연동사를 삭제하시겠습니까?\n하위에 등록된 상품 코드가 없어야 삭제 가능합니다.');" style="margin:0;">
@csrf @method('DELETE')
<button type="submit" class="lbtn lbtn--sm lbtn--danger">삭제</button>
</form>
</div>
</div>
<div class="cat-children">
@foreach($pv['children'] as $cd)
<div class="cat-item depth-2" data-id="{{ $cd['id'] }}">
<div class="cat-info">
<span class="pill {{ $cd['is_active'] ? 'pill--ok' : 'pill--muted' }}">
{{ $cd['is_active'] ? 'ON' : 'OFF' }}
</span>
<span>{{ $cd['name'] }}</span>
<span class="a-muted" style="font-size:12px;">({{ $cd['api_code'] }})</span>
</div>
<div class="cat-actions">
<button type="button" class="lbtn lbtn--ghost btn-sort" onclick="moveRow(this, -1)"></button>
<button type="button" class="lbtn lbtn--ghost btn-sort" onclick="moveRow(this, 1)"></button>
<button type="button" class="lbtn lbtn--sm lbtn--ghost" style="margin-left:8px;" onclick="editCode({{ json_encode($cd) }})">수정</button>
<form action="{{ route('admin.sale-codes.code.destroy', $cd['id']) }}" method="POST" onsubmit="return confirm('이 상품 코드를 매핑에서 삭제하시겠습니까?');" style="margin:0;">
@csrf @method('DELETE')
<button type="submit" class="lbtn lbtn--sm lbtn--danger">삭제</button>
</form>
</div>
</div>
@endforeach
</div>
</div>
@empty
<div style="padding: 20px; text-align: center;" class="a-muted">등록된 연동사 코드가 없습니다.</div>
@endforelse
</div>
</div>
<div>
<div class="a-card" style="padding:16px; margin-bottom:20px; border-top: 3px solid rgba(52,211,153,.8);">
<div style="font-weight:900; font-size:16px; margin-bottom:16px;" id="codeTitle">📦 상품 코드 등록</div>
<form id="codeForm" method="POST" action="{{ route('admin.sale-codes.code.store') }}">
@csrf
<input type="hidden" name="_method" id="codeMethod" value="POST">
<div style="display:grid; gap:12px;">
<div class="a-field">
<label class="a-label">연동사 선택</label>
<select class="a-input" name="provider_id" id="codeProviderId" required>
<option value="">-- 연동사 선택 --</option>
@foreach($tree as $pv)
<option value="{{ $pv['id'] }}">{{ $pv['name'] }}</option>
@endforeach
</select>
</div>
<div class="a-field">
<label class="a-label">실제 API 상품 코드 (: CULTURE)</label>
<input class="a-input" name="api_code" id="codeApi" required>
</div>
<div class="a-field">
<label class="a-label">상품 매핑 명칭 (: 문화상품권)</label>
<input class="a-input" name="name" id="codeName" required>
</div>
<div class="a-field">
<label class="a-label">사용 여부</label>
<select class="a-input" name="is_active" id="codeActive">
<option value="1">ON (사용)</option>
<option value="0">OFF (숨김)</option>
</select>
</div>
<div style="display:flex; gap:10px; margin-top:10px;">
<button type="button" class="lbtn lbtn--ghost" style="flex:1;" onclick="resetCodeForm()">신규등록 초기화</button>
<button type="submit" class="lbtn lbtn--primary" style="flex:1;">저장</button>
</div>
</div>
</form>
</div>
<div class="a-card" style="padding:16px; border-top: 3px solid rgba(96,165,250,.8);">
<div style="font-weight:900; font-size:16px; margin-bottom:16px;" id="pvTitle">🏢 신규 연동사 (Provider) 등록</div>
<form id="pvForm" method="POST" action="{{ route('admin.sale-codes.provider.store') }}">
@csrf
<input type="hidden" name="_method" id="pvMethod" value="POST">
<div style="display:grid; gap:12px;">
<div class="a-field">
<label class="a-label">연동사 코드 (: DANAL)</label>
<input class="a-input" name="code" id="pvCode" required>
</div>
<div class="a-field">
<label class="a-label">연동사 노출명 (: 다날)</label>
<input class="a-input" name="name" id="pvName" required>
</div>
<div class="a-field">
<label class="a-label">사용 여부</label>
<select class="a-input" name="is_active" id="pvActive">
<option value="1">ON (전체 사용)</option>
<option value="0">OFF (전체 장애/중단)</option>
</select>
</div>
<div style="display:flex; gap:10px; margin-top:10px;">
<button type="button" class="lbtn lbtn--ghost" style="flex:1;" onclick="resetPvForm()">신규등록 초기화</button>
<button type="submit" class="lbtn lbtn--primary" style="flex:1;">저장</button>
</div>
</div>
</form>
</div>
</div>
</div>
@endsection
@push('scripts')
<script>
const baseUrl = '{{ route('admin.sale-codes.index') }}';
// 상품 코드 수정 세팅
function editCode(cd) {
document.getElementById('codeTitle').innerText = '📦 상품 코드 수정 (#'+cd.id+')';
document.getElementById('codeForm').action = baseUrl + '/code/' + cd.id;
document.getElementById('codeMethod').value = 'PUT';
document.getElementById('codeProviderId').value = cd.provider_id;
document.getElementById('codeApi').value = cd.api_code;
document.getElementById('codeName').value = cd.name;
document.getElementById('codeActive').value = cd.is_active;
}
function resetCodeForm() {
document.getElementById('codeTitle').innerText = '📦 상품 코드 등록';
document.getElementById('codeForm').action = '{{ route('admin.sale-codes.code.store') }}';
document.getElementById('codeMethod').value = 'POST';
document.getElementById('codeProviderId').value = '';
document.getElementById('codeApi').value = '';
document.getElementById('codeName').value = '';
document.getElementById('codeActive').value = '1';
}
// 연동사 수정 세팅
function editProvider(pv) {
document.getElementById('pvTitle').innerText = '🏢 연동사 수정 (#'+pv.id+')';
document.getElementById('pvForm').action = baseUrl + '/provider/' + pv.id;
document.getElementById('pvMethod').value = 'PUT';
document.getElementById('pvCode').value = pv.code;
document.getElementById('pvCode').readOnly = true; // 코드는 수정 불가
document.getElementById('pvName').value = pv.name;
document.getElementById('pvActive').value = pv.is_active;
}
function resetPvForm() {
document.getElementById('pvTitle').innerText = '🏢 신규 연동사 등록';
document.getElementById('pvForm').action = '{{ route('admin.sale-codes.provider.store') }}';
document.getElementById('pvMethod').value = 'POST';
document.getElementById('pvCode').value = '';
document.getElementById('pvCode').readOnly = false;
document.getElementById('pvName').value = '';
document.getElementById('pvActive').value = '1';
}
// 드래그 앤 드롭 대신 화살표 버튼 정렬 (상품 코드 전용)
function moveRow(btn, direction) {
const item = btn.closest('.cat-item');
const container = item.parentNode;
if (direction === -1 && item.previousElementSibling) {
container.insertBefore(item, item.previousElementSibling);
saveSort(container);
} else if (direction === 1 && item.nextElementSibling) {
container.insertBefore(item.nextElementSibling, item);
saveSort(container);
}
}
function saveSort(container) {
const ids = Array.from(container.children).map(el => el.getAttribute('data-id'));
fetch('{{ route('admin.sale-codes.code.sort') }}', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
'Accept': 'application/json'
},
body: JSON.stringify({ ids: ids })
});
}
</script>
@endpush

View File

@ -0,0 +1,168 @@
@extends('admin.layouts.app')
@section('title', '권종 등록')
@section('page_title', '권종 등록')
@section('page_desc', '상품별 권종/가격(SKU)을 등록합니다.')
@push('head')
<style>
/* skus create only (admins edit 스타일 준수) */
.lbtn{padding:8px 12px;font-size:13px;border-radius:12px;line-height:1.1;text-decoration:none;display:inline-flex;align-items:center;justify-content:center;gap:6px;
border:1px solid rgba(255,255,255,.10);background:rgba(255,255,255,.06);color:inherit;cursor:pointer;}
.lbtn:hover{background:rgba(255,255,255,.10);text-decoration:none;}
.lbtn--primary{background:rgba(59,130,246,.88);border-color:rgba(59,130,246,.95);color:#fff;}
.lbtn--primary:hover{background:rgba(59,130,246,.98);}
.lbtn--ghost{background:transparent;}
.lbtn--wide{padding:10px 14px;font-weight:800;}
.pill{display:inline-flex;align-items:center;gap:6px;padding:6px 10px;border-radius:999px;font-size:12px;
border:1px solid rgba(255,255,255,.10);background:rgba(255,255,255,.06);}
.pill--muted{opacity:.9;}
.mono{padding:4px 8px;border-radius:10px;background:rgba(255,255,255,.06);border:1px solid rgba(255,255,255,.10);
font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;font-size:12px;}
.actions{position:sticky;bottom:10px;z-index:5;margin-top:12px;
display:flex;justify-content:space-between;gap:12px;flex-wrap:wrap;align-items:center;
padding:12px;border-radius:16px;border:1px solid rgba(255,255,255,.10);background:rgba(0,0,0,.25);backdrop-filter:blur(10px);}
.actions__right{display:flex;gap:8px;flex-wrap:wrap;align-items:center;}
.grid{display:grid;grid-template-columns:1fr;gap:12px;max-width:900px;}
@media (min-width: 980px){ .grid{grid-template-columns:1fr 1fr;} }
.grid .span2{grid-column:1 / -1;}
.help{font-size:12px;margin-top:6px;}
</style>
@endpush
@section('content')
@php
$qs = request()->only(['q','category_id','product_id','status','page']);
@endphp
<div class="a-card" style="padding:16px; margin-bottom:16px;">
<div style="display:flex; justify-content:space-between; align-items:flex-start; gap:12px; flex-wrap:wrap;">
<div>
<div style="font-weight:900; font-size:16px;">SKU 등록</div>
<div class="a-muted" style="font-size:12px; margin-top:4px;">상품별 권종/가격(SKU) 등록합니다.</div>
</div>
<a class="lbtn lbtn--ghost"
href="{{ route('admin.skus.index', $qs) }}">
목록
</a>
</div>
</div>
<form id="skuCreateForm"
method="POST"
action="{{ route('admin.skus.store') }}"
onsubmit="this.querySelector('button[type=submit]')?.setAttribute('disabled','disabled');">
@csrf
<div class="a-card" style="padding:16px;">
<div class="grid">
<div class="a-field span2">
<label class="a-label">상품 선택</label>
<select class="a-input" name="product_id" id="product_id">
<option value="">상품을 선택하세요</option>
@foreach(($products ?? []) as $p)
@php
$pid = (string)($p->id ?? '');
$label = trim((string)($p->parent_category_name ?? ''));
if ($label !== '') $label .= ' / '.($p->category_name ?? '');
else $label = (string)($p->category_name ?? '');
$label = trim($label);
$label = $label !== '' ? ($label.' - '.($p->name ?? '')) : ($p->name ?? '-');
@endphp
<option value="{{ $pid }}" {{ (string)old('product_id')===$pid ? 'selected':'' }}>
{{ $label }}
</option>
@endforeach
</select>
</div>
<div class="a-field">
<label class="a-label">SKU 코드(선택)</label>
<input class="a-input" name="sku_code" value="{{ old('sku_code') }}" placeholder="예: GOOGLE_10000">
<div class="a-muted help"> 내부 관리용 식별자(없어도 )</div>
</div>
<div class="a-field">
<label class="a-label">정렬(작을수록 우선)</label>
<input class="a-input" type="number" name="sort" min="0" value="{{ old('sort', '0') }}">
</div>
<div class="a-field">
<label class="a-label">권면가()</label>
<input class="a-input" type="number" name="face_value" id="face_value" min="0" value="{{ old('face_value') }}" placeholder="예: 10000">
</div>
<div class="a-field">
<label class="a-label">정상가()</label>
<input class="a-input" type="number" name="normal_price" id="normal_price" min="0" value="{{ old('normal_price') }}" placeholder="예: 10000">
</div>
<div class="a-field">
<label class="a-label">할인율(%)</label>
<input class="a-input" type="number" name="discount_rate" id="discount_rate" min="0" max="99.99" step="0.01" value="{{ old('discount_rate', '0.00') }}">
</div>
<div class="a-field">
<label class="a-label">판매가 미리보기</label>
<div><span class="mono" id="sale_price_preview">-</span></div>
<div class="a-muted help"> 저장 서버에서 계산되어 sale_price에 저장됩니다.</div>
</div>
<div class="a-field">
<label class="a-label">재고방식</label>
<select class="a-input" name="stock_mode">
@php $sm = (string)old('stock_mode','infinite'); @endphp
<option value="infinite" {{ $sm==='infinite'?'selected':'' }}>무한(infinite)</option>
<option value="limited" {{ $sm==='limited'?'selected':'' }}>한정(limited)</option>
</select>
</div>
<div class="a-field">
<label class="a-label">상태</label>
<select class="a-input" name="status">
@php $st = (string)old('status','active'); @endphp
<option value="active" {{ $st==='active'?'selected':'' }}>노출</option>
<option value="hidden" {{ $st==='hidden'?'selected':'' }}>숨김</option>
</select>
</div>
</div>
<div class="actions">
<div class="a-muted" style="font-size:12px;">
권면가/정상가/할인율 기반으로 판매가가 계산됩니다.
</div>
<div class="actions__right">
<a class="lbtn lbtn--ghost" href="{{ route('admin.skus.index', $qs) }}">취소</a>
<button class="lbtn lbtn--primary lbtn--wide" type="submit">등록</button>
</div>
</div>
</div>
</form>
<script>
(function(){
const n = document.getElementById('normal_price');
const r = document.getElementById('discount_rate');
const out = document.getElementById('sale_price_preview');
const fmt = (x) => {
try { return Number(x).toLocaleString('ko-KR'); } catch (e) { return String(x); }
};
const calc = () => {
const normal = parseInt(n?.value || '0', 10) || 0;
let rate = parseFloat(r?.value || '0') || 0;
if (rate < 0) rate = 0;
if (rate > 99.99) rate = 99.99;
const sale = Math.floor(normal * (100 - rate) / 100);
out.textContent = (normal > 0) ? (fmt(sale) + ' 원') : '-';
};
n?.addEventListener('input', calc);
r?.addEventListener('input', calc);
calc();
})();
</script>
@endsection

View File

@ -0,0 +1,237 @@
@extends('admin.layouts.app')
@section('title', 'SKU 수정')
@section('page_title', 'SKU 수정')
@section('page_desc', 'SKU(권종/가격)를 수정합니다.')
@push('head')
<style>
/* skus edit only (admins edit 스타일 준수) */
.lbtn{padding:8px 12px;font-size:13px;border-radius:12px;line-height:1.1;text-decoration:none;display:inline-flex;align-items:center;justify-content:center;gap:6px;
border:1px solid rgba(255,255,255,.10);background:rgba(255,255,255,.06);color:inherit;cursor:pointer;}
.lbtn:hover{background:rgba(255,255,255,.10);text-decoration:none;}
.lbtn--primary{background:rgba(59,130,246,.88);border-color:rgba(59,130,246,.95);color:#fff;}
.lbtn--primary:hover{background:rgba(59,130,246,.98);}
.lbtn--danger{background:rgba(244,63,94,.88);border-color:rgba(244,63,94,.95);color:#fff;}
.lbtn--danger:hover{background:rgba(244,63,94,.98);}
.lbtn--ghost{background:transparent;}
.lbtn--wide{padding:10px 14px;font-weight:800;}
.pill{display:inline-flex;align-items:center;gap:6px;padding:6px 10px;border-radius:999px;font-size:12px;
border:1px solid rgba(255,255,255,.10);background:rgba(255,255,255,.06);}
.pill--ok{border-color:rgba(34,197,94,.35);background:rgba(34,197,94,.12);}
.pill--bad{border-color:rgba(244,63,94,.35);background:rgba(244,63,94,.10);}
.pill--muted{opacity:.9;}
.mono{padding:4px 8px;border-radius:10px;background:rgba(255,255,255,.06);border:1px solid rgba(255,255,255,.10);
font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;font-size:12px;}
.kvgrid{display:grid;grid-template-columns:1fr;gap:12px;}
@media (min-width: 980px){ .kvgrid{grid-template-columns:1fr 1fr 1fr;} }
.kv{padding:14px;border-radius:16px;border:1px solid rgba(255,255,255,.10);background:rgba(255,255,255,.04);}
.kv .k{font-size:12px;opacity:.8;margin-bottom:6px;}
.kv .v{font-weight:900;}
.actions{position:sticky;bottom:10px;z-index:5;margin-top:12px;
display:flex;justify-content:space-between;gap:12px;flex-wrap:wrap;align-items:center;
padding:12px;border-radius:16px;border:1px solid rgba(255,255,255,.10);background:rgba(0,0,0,.25);backdrop-filter:blur(10px);}
.actions__right{display:flex;gap:8px;flex-wrap:wrap;align-items:center;}
.grid{display:grid;grid-template-columns:1fr;gap:12px;max-width:900px;}
@media (min-width: 980px){ .grid{grid-template-columns:1fr 1fr;} }
.grid .span2{grid-column:1 / -1;}
.help{font-size:12px;margin-top:6px;}
</style>
@endpush
@section('content')
@php
$p = $sku ?? null;
$qs = request()->only(['q','category_id','product_id','status','page']);
$st = (string)old('status', (string)($p->status ?? 'active'));
$pill = $st === 'active' ? 'pill--ok' : 'pill--bad';
$stText = $st === 'active' ? '노출' : '숨김';
$face = (int)old('face_value', (int)($p->face_value ?? 0));
$normal = (int)old('normal_price', (int)($p->normal_price ?? 0));
$rate = (string)old('discount_rate', (string)($p->discount_rate ?? '0.00'));
$sale = (int)($p->sale_price ?? 0);
@endphp
<div class="a-card" style="padding:16px; margin-bottom:16px;">
<div style="display:flex; justify-content:space-between; align-items:flex-start; gap:12px; flex-wrap:wrap;">
<div>
<div style="font-weight:900; font-size:16px;">SKU 수정</div>
<div class="a-muted" style="font-size:12px; margin-top:4px;">
#{{ (int)($p->id ?? 0) }}
</div>
</div>
<a class="lbtn lbtn--ghost"
href="{{ route('admin.skus.index', $qs) }}">
목록
</a>
</div>
</div>
{{-- 요약 --}}
<div class="kvgrid" style="margin-bottom:16px;">
<div class="kv">
<div class="k">상태</div>
<div class="v"><span class="pill {{ $pill }}"> {{ $stText }}</span></div>
</div>
<div class="kv">
<div class="k">현재 판매가</div>
<div class="v">{{ number_format($sale) }} </div>
</div>
<div class="kv">
<div class="k">재고방식</div>
<div class="v">{{ (string)($p->stock_mode ?? '-') }}</div>
</div>
<div class="kv">
<div class="k">생성</div>
<div class="v">{{ $p->created_at ?? '-' }}</div>
</div>
<div class="kv">
<div class="k">최근 수정</div>
<div class="v">{{ $p->updated_at ?? '-' }}</div>
</div>
<div class="kv">
<div class="k">상품 ID</div>
<div class="v"><span class="mono">{{ (int)($p->product_id ?? 0) }}</span></div>
</div>
</div>
{{-- 수정 --}}
<form id="skuEditForm"
method="POST"
action="{{ route('admin.skus.update', ['id'=>(int)($p->id ?? 0)] ) }}"
onsubmit="this.querySelector('button[type=submit]')?.setAttribute('disabled','disabled');">
@csrf
@method('PUT')
{{-- 저장 목록 복귀 같은 필터/페이지 유지용 --}}
@foreach($qs as $k=>$v)
<input type="hidden" name="{{ $k }}" value="{{ $v }}">
@endforeach
<div class="a-card" style="padding:16px;">
<div class="grid">
<div class="a-field span2">
<label class="a-label">상품</label>
<div class="a-muted" style="font-size:13px;">
상품 변경은 운영상 위험해서(재고/정산/장부 연계) 기본은 고정입니다.
<span class="mono" style="margin-left:8px;">product_id={{ (int)($p->product_id ?? 0) }}</span>
</div>
</div>
<div class="a-field">
<label class="a-label">SKU 코드(선택)</label>
<input class="a-input" name="sku_code" value="{{ old('sku_code', (string)($p->sku_code ?? '')) }}" placeholder="예: GOOGLE_10000">
</div>
<div class="a-field">
<label class="a-label">정렬(작을수록 우선)</label>
<input class="a-input" type="number" name="sort" min="0" value="{{ old('sort', (string)($p->sort ?? '0')) }}">
</div>
<div class="a-field">
<label class="a-label">권면가()</label>
<input class="a-input" type="number" name="face_value" id="face_value" min="0" value="{{ old('face_value', (string)($p->face_value ?? '')) }}">
</div>
<div class="a-field">
<label class="a-label">정상가()</label>
<input class="a-input" type="number" name="normal_price" id="normal_price" min="0" value="{{ old('normal_price', (string)($p->normal_price ?? '')) }}">
</div>
<div class="a-field">
<label class="a-label">할인율(%)</label>
<input class="a-input" type="number" name="discount_rate" id="discount_rate" min="0" max="99.99" step="0.01"
value="{{ old('discount_rate', (string)($p->discount_rate ?? '0.00')) }}">
</div>
<div class="a-field">
<label class="a-label">판매가 미리보기</label>
<div><span class="mono" id="sale_price_preview">-</span></div>
<div class="a-muted help"> 저장 서버에서 계산되어 sale_price에 저장됩니다.</div>
</div>
<div class="a-field">
<label class="a-label">재고방식</label>
<select class="a-input" name="stock_mode">
@php $sm = (string)old('stock_mode', (string)($p->stock_mode ?? 'infinite')); @endphp
<option value="infinite" {{ $sm==='infinite'?'selected':'' }}>무한(infinite)</option>
<option value="limited" {{ $sm==='limited'?'selected':'' }}>한정(limited)</option>
</select>
</div>
<div class="a-field">
<label class="a-label">상태</label>
<select class="a-input" name="status">
@php $stOpt = (string)old('status', (string)($p->status ?? 'active')); @endphp
<option value="active" {{ $stOpt==='active'?'selected':'' }}>노출</option>
<option value="hidden" {{ $stOpt==='hidden'?'selected':'' }}>숨김</option>
</select>
</div>
</div>
<div class="actions">
<div class="a-muted" style="font-size:12px;">
권면가/정상가/할인율 기반으로 판매가가 계산됩니다.
</div>
<div class="actions__right">
<button class="lbtn lbtn--danger"
type="submit"
form="skuDeleteForm"
onclick="return confirm('정말 삭제하시겠습니까? (연동/정산 연결 전까지만 삭제 권장)');">
삭제
</button>
<a class="lbtn lbtn--ghost" href="{{ route('admin.skus.index', $qs) }}">목록</a>
<button class="lbtn lbtn--primary lbtn--wide" type="submit">저장</button>
</div>
</div>
</div>
</form>
{{-- 삭제 (중첩 form 금지) --}}
<form id="skuDeleteForm"
method="POST"
action="{{ route('admin.skus.destroy', ['id'=>(int)($p->id ?? 0)] ) }}">
@csrf
@method('DELETE')
@foreach($qs as $k=>$v)
<input type="hidden" name="{{ $k }}" value="{{ $v }}">
@endforeach
</form>
<script>
(function(){
const n = document.getElementById('normal_price');
const r = document.getElementById('discount_rate');
const out = document.getElementById('sale_price_preview');
const fmt = (x) => {
try { return Number(x).toLocaleString('ko-KR'); } catch (e) { return String(x); }
};
const calc = () => {
const normal = parseInt(n?.value || '0', 10) || 0;
let rate = parseFloat(r?.value || '0') || 0;
if (rate < 0) rate = 0;
if (rate > 99.99) rate = 99.99;
const sale = Math.floor(normal * (100 - rate) / 100);
out.textContent = (normal > 0) ? (fmt(sale) + ' 원') : '-';
};
n?.addEventListener('input', calc);
r?.addEventListener('input', calc);
calc();
})();
</script>
@endsection

View File

@ -0,0 +1,268 @@
@extends('admin.layouts.app')
@section('title', '권종 관리')
@section('page_title', '권종 관리')
@section('page_desc', '상품별 권종/가격(SKU)을 관리합니다.')
@section('content_class', 'a-content--full')
@push('head')
<style>
/* skus index only (members index 스타일 준수) */
.bar{display:flex;justify-content:space-between;align-items:flex-end;gap:12px;flex-wrap:wrap;}
.bar__left .t{font-weight:900;font-size:16px;}
.bar__left .d{font-size:12px;margin-top:4px;}
.bar__right{display:flex;gap:10px;flex-wrap:wrap;align-items:flex-end;}
.filters{display:flex;gap:8px;flex-wrap:wrap;align-items:flex-end;}
.filters .q{width:260px;}
.filters .cat{width:220px;}
.filters .prd{width:260px;}
.filters .st{width:140px;}
.lbtn{padding:8px 12px;font-size:13px;border-radius:12px;line-height:1.1;text-decoration:none;display:inline-flex;align-items:center;justify-content:center;gap:6px;
border:1px solid rgba(255,255,255,.10);background:rgba(255,255,255,.06);color:inherit;cursor:pointer;}
.lbtn:hover{background:rgba(255,255,255,.10);text-decoration:none;}
.lbtn--ghost{background:transparent;}
.lbtn--sm{padding:7px 10px;font-size:12px;border-radius:11px;}
.lbtn--primary{background:rgba(59,130,246,.88);border-color:rgba(59,130,246,.95);color:#fff;}
.lbtn--primary:hover{background:rgba(59,130,246,.98);}
.pill{display:inline-flex;align-items:center;gap:6px;padding:6px 10px;border-radius:999px;font-size:12px;
border:1px solid rgba(255,255,255,.10);background:rgba(255,255,255,.06);}
.pill--ok{border-color:rgba(34,197,94,.35);background:rgba(34,197,94,.12);}
.pill--bad{border-color:rgba(244,63,94,.35);background:rgba(244,63,94,.10);}
.pill--muted{opacity:.9;}
.mono{padding:4px 8px;border-radius:10px;background:rgba(255,255,255,.06);border:1px solid rgba(255,255,255,.10);
font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;font-size:12px;}
.table td{vertical-align:top;}
.sub{font-size:12px;margin-top:6px;line-height:1.35;}
</style>
@endpush
@section('content')
@php
$filters = $filters ?? [];
$catId = (string)($filters['category_id'] ?? '');
$prdId = (string)($filters['product_id'] ?? '');
$stSel = (string)($filters['status'] ?? '');
$q = (string)($filters['q'] ?? '');
$statusPill = function(?string $st): string {
$st = (string)($st ?? '');
return match($st) {
'active' => 'pill--ok',
'hidden' => 'pill--bad',
default => 'pill--muted',
};
};
$statusLabel = function(?string $st): string {
$st = (string)($st ?? '');
return match($st) {
'active' => '노출',
'hidden' => '숨김',
default => $st ?: '-',
};
};
$stockLabel = function(?string $m): string {
$m = (string)($m ?? '');
return match($m) {
'infinite' => '무한',
'limited' => '한정',
default => $m ?: '-',
};
};
@endphp
<div class="a-card" style="padding:16px; margin-bottom:16px;">
<div class="bar">
<div class="bar__left">
<div class="t">권종관리</div>
<div class="a-muted d">상품별 권종/가격(SKU) 관리합니다.</div>
</div>
<div class="bar__right">
<a class="lbtn lbtn--primary" href="{{ route('admin.skus.create') }}">+ 권종 등록</a>
<form method="GET" action="{{ route('admin.skus.index') }}" class="filters">
<div>
<select class="a-input cat" name="category_id" id="category_id">
<option value="">카테고리 전체</option>
@foreach(($categories ?? []) as $c)
@php
$cid = (string)($c->id ?? '');
$txt = trim((string)($c->parent_name ?? ''));
$txt = $txt !== '' ? ($txt.' / '.($c->name ?? '')) : ($c->name ?? '-');
@endphp
<option value="{{ $cid }}" {{ $catId===$cid ? 'selected':'' }}>
{{ $txt }}
</option>
@endforeach
</select>
</div>
<div>
<select class="a-input prd" name="product_id" id="product_id">
<option value="">상품 전체</option>
@foreach(($products ?? []) as $p)
@php
$pid = (string)($p->id ?? '');
$pcat = (string)($p->category_id ?? '');
$label = trim((string)($p->parent_category_name ?? ''));
if ($label !== '') $label .= ' / '.($p->category_name ?? '');
else $label = (string)($p->category_name ?? '');
$label = trim($label);
$label = $label !== '' ? ($label.' - '.($p->name ?? '')) : ($p->name ?? '-');
@endphp
<option value="{{ $pid }}"
data-cat="{{ $pcat }}"
{{ $prdId===$pid ? 'selected':'' }}>
{{ $label }}
</option>
@endforeach
</select>
</div>
<div>
<select class="a-input st" name="status">
<option value="">상태 전체</option>
<option value="active" {{ $stSel==='active'?'selected':'' }}>노출</option>
<option value="hidden" {{ $stSel==='hidden'?'selected':'' }}>숨김</option>
</select>
</div>
<div>
<input class="a-input q"
name="q"
value="{{ $q }}"
placeholder="상품명 / sku_code 검색">
</div>
<div style="display:flex; gap:8px; align-items:flex-end;">
<button class="lbtn lbtn--ghost" type="submit">검색</button>
<a class="lbtn lbtn--ghost" href="{{ route('admin.skus.index') }}">초기화</a>
</div>
</form>
</div>
</div>
</div>
<div class="a-card" style="padding:16px;">
<div class="a-muted" style="margin-bottom:10px;">
<b>{{ $page->total() }}</b>
</div>
<div style="overflow:auto;">
<table class="a-table table" style="width:100%; min-width:1200px;">
<thead>
<tr>
<th style="width:80px;">ID</th>
<th style="width:360px;">상품</th>
<th style="width:140px;">권면가</th>
<th style="width:140px;">정상가</th>
<th style="width:110px;">할인율</th>
<th style="width:140px;">판매가</th>
<th style="width:120px;">재고방식</th>
<th style="width:120px;">상태</th>
<th style="width:170px;">수정일</th>
<th style="width:90px; text-align:right;">관리</th>
</tr>
</thead>
<tbody>
@forelse($page as $row)
@php
$id = (int)($row->id ?? 0);
$st = (string)($row->status ?? '');
$pill = $statusPill($st);
$stText = $statusLabel($st);
$pname = (string)($row->product_name ?? '-');
$c1 = (string)($row->parent_category_name ?? '');
$c2 = (string)($row->category_name ?? '');
$catText = trim($c1) !== '' ? ($c1.' / '.$c2) : ($c2 ?: '-');
$face = (int)($row->face_value ?? 0);
$normal = (int)($row->normal_price ?? 0);
$rate = (string)($row->discount_rate ?? '0.00');
$sale = (int)($row->sale_price ?? 0);
$smode = $stockLabel($row->stock_mode ?? null);
$updated = $row->updated_at ?? '-';
@endphp
<tr>
<td class="a-muted">{{ $id }}</td>
<td>
<div style="font-weight:900;">{{ $pname }}</div>
<div class="a-muted sub">{{ $catText }}</div>
@if(!empty($row->sku_code))
<div class="sub"><span class="mono">{{ $row->sku_code }}</span></div>
@endif
</td>
<td><span class="mono">{{ number_format($face) }}</span> </td>
<td class="a-muted">{{ number_format($normal) }} </td>
<td class="a-muted">{{ $rate }}%</td>
<td><b>{{ number_format($sale) }}</b> </td>
<td class="a-muted">{{ $smode }}</td>
<td>
<span class="pill {{ $pill }}"> {{ $stText }}</span>
</td>
<td class="a-muted">{{ $updated }}</td>
<td style="text-align:right;">
<a class="lbtn lbtn--ghost lbtn--sm"
href="{{ route('admin.skus.edit', ['id'=>$id] + request()->only(['q','category_id','product_id','status','page'])) }}">
수정
</a>
</td>
</tr>
@empty
<tr>
<td colspan="10" class="a-muted" style="padding:18px;">데이터가 없습니다.</td>
</tr>
@endforelse
</tbody>
</table>
</div>
<div style="margin-top:12px;">
{{ $page->onEachSide(1)->links('vendor.pagination.admin') }}
</div>
</div>
<script>
(function(){
const catSel = document.getElementById('category_id');
const prdSel = document.getElementById('product_id');
if (!catSel || !prdSel) return;
const apply = () => {
const cat = catSel.value || '';
const cur = prdSel.value || '';
let hasCur = false;
Array.from(prdSel.options).forEach((opt, idx) => {
if (idx === 0) return; // "상품 전체"
const oc = opt.getAttribute('data-cat') || '';
const ok = (cat === '' || oc === cat);
opt.hidden = !ok;
if (ok && opt.value === cur) hasCur = true;
});
// 현재 선택된 상품이 필터에서 숨겨지면 전체로 되돌림
if (cur !== '' && !hasCur) prdSel.value = '';
};
catSel.addEventListener('change', apply);
apply();
})();
</script>
@endsection

View File

@ -19,7 +19,7 @@
<nav class="desktop-nav" aria-label="주요 메뉴"> <nav class="desktop-nav" aria-label="주요 메뉴">
<a href="/" class="nav-link">HOME</a> <a href="/" class="nav-link">HOME</a>
<a href="/shop" class="nav-link nav-link--exchange">SHOP</a> <a href="/product/list" class="nav-link nav-link--exchange">SHOP</a>
{{-- <a href="/exchange" class="nav-link nav-link--exchange">상품권현금교환</a>--}} {{-- <a href="/exchange" class="nav-link nav-link--exchange">상품권현금교환</a>--}}
<a href="/mypage/info" class="nav-link">마이페이지</a> <a href="/mypage/info" class="nav-link">마이페이지</a>
<a href="/cs/notice" class="nav-link">고객센터</a> <a href="/cs/notice" class="nav-link">고객센터</a>
@ -28,7 +28,7 @@
<!-- Center: Search (Desktop) --> <!-- Center: Search (Desktop) -->
<div class="search-bar"> <div class="search-bar">
<form action="/shop" method="GET"> <form action="/product/list" method="GET">
<input type="text" name="search" class="search-input" placeholder="상품권/브랜드 검색"> <input type="text" name="search" class="search-input" placeholder="상품권/브랜드 검색">
<button type="submit" class="search-icon" aria-label="검색"> <button type="submit" class="search-icon" aria-label="검색">
<svg width="20" height="20" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg width="20" height="20" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@ -44,7 +44,7 @@
@if($isLogin) @if($isLogin)
{{-- CTA: 핵심 행동 1 --}} {{-- CTA: 핵심 행동 1 --}}
<a href="/shop" class="btn btn-primary" style="padding: 8px 18px;"> <a href="/product/list" class="btn btn-primary" style="padding: 8px 18px;">
상품권 구매 상품권 구매
</a> </a>

View File

@ -4,7 +4,7 @@
<div class="subpage-wrap"> <div class="subpage-wrap">
<div class="container"> <div class="container">
{{-- Breadcrumb (optional) --}} {{-- Breadcrumb --}}
@isset($breadcrumbs) @isset($breadcrumbs)
@include('web.partials.breadcrumb', ['items' => $breadcrumbs]) @include('web.partials.breadcrumb', ['items' => $breadcrumbs])
@endisset @endisset
@ -17,100 +17,44 @@
$isMypagePage = request()->is('mypage') || request()->is('mypage/*') || request()->routeIs('web.mypage.*'); $isMypagePage = request()->is('mypage') || request()->is('mypage/*') || request()->routeIs('web.mypage.*');
$isPolicyPage = request()->is('policy') || request()->is('policy/*') || request()->routeIs('web.policy.*'); $isPolicyPage = request()->is('policy') || request()->is('policy/*') || request()->routeIs('web.policy.*');
// [추가] 상품 페이지 섹션 감지
$isShopPage = request()->is('product') || request()->is('product/*') || request()->routeIs('web.product.*');
// CS subnav 자동 주입 // CS subnav 자동 주입
if ($isCsPage && empty($subnavItems)) { if ($isCsPage && empty($subnavItems)) {
$subnavItems = collect(config('web.cs_tabs', [])) $subnavItems = collect(config('web.cs_tabs', []))->map(function ($t) {
->map(function ($t) { return ['label' => $t['label'] ?? '', 'url' => Route::has($t['route'] ?? '') ? route($t['route']) : '#', 'key' => $t['key'] ?? null];
$url = '#'; })->all();
if (!empty($t['route']) && \Illuminate\Support\Facades\Route::has($t['route'])) {
$url = route($t['route']);
}
return [
'label' => $t['label'] ?? '',
'url' => $url,
'key' => $t['key'] ?? null,
];
})
->values()
->all();
} }
// MYPAGE subnav 자동 주입 // MYPAGE subnav 자동 주입
if ($isMypagePage && empty($subnavItems)) { if ($isMypagePage && empty($subnavItems)) {
$subnavItems = collect(config('web.mypage_tabs', [])) $subnavItems = collect(config('web.mypage_tabs', []))->map(function ($t) {
->map(function ($t) { return ['label' => $t['label'] ?? '', 'url' => Route::has($t['route'] ?? '') ? route($t['route']) : '#', 'key' => $t['key'] ?? null];
$url = '#'; })->all();
if (!empty($t['route']) && \Illuminate\Support\Facades\Route::has($t['route'])) {
$url = route($t['route']);
}
return [
'label' => $t['label'] ?? '',
'url' => $url,
'key' => $t['key'] ?? null,
];
})
->values()
->all();
} }
// PolicyPage subnav 자동 주입 // active key 설정
if ($isPolicyPage && empty($subnavItems)) {
$subnavItems = collect(config('web.policy_tabs', []))
->map(function ($t) {
$url = '#';
if (!empty($t['route']) && \Illuminate\Support\Facades\Route::has($t['route'])) {
$url = route($t['route']);
}
return [
'label' => $t['label'] ?? '',
'url' => $url,
'key' => $t['key'] ?? null,
];
})
->values()
->all();
}
// active key 우선순위: 각 섹션 전용 변수 -> 기존 subnavActive
$subnavActive = $csActive ?? $mypageActive ?? ($subnavActive ?? null); $subnavActive = $csActive ?? $mypageActive ?? ($subnavActive ?? null);
@endphp @endphp
@if(!empty($heroSlides)) @if(!empty($heroSlides))
@include('web.partials.hero-slider', [ @include('web.partials.hero-slider', ['slides' => $heroSlides, 'variant' => 'compact', 'id' => 'hero-sub'])
'slides' => $heroSlides,
'variant' => 'compact',
'id' => 'hero-sub'
])
@endif @endif
{{-- Body --}}
<div class="subpage-grid"> <div class="subpage-grid">
{{-- 고객센터 cs-tabs --}}
@if($isCsPage) @if($isCsPage)
@include('web.partials.cs-tabs', [ @include('web.partials.cs-tabs', ['activeKey' => $subnavActive])
'activeKey' => $subnavActive
])
{{-- 마이페이지 mypage-tabs --}}
@elseif($isMypagePage) @elseif($isMypagePage)
@include('web.partials.mypage-tabs', [ @include('web.partials.mypage-tabs', ['activeKey' => $subnavActive])
'activeKey' => $subnavActive
])
{{-- pinforyou mypage-tabs --}}
@elseif($isPolicyPage) @elseif($isPolicyPage)
@include('web.partials.policy-tabs', [ @include('web.partials.policy-tabs', ['activeKey' => $subnavActive])
'activeKey' => $subnavActive
])
{{-- 일반 서브페이지는 기존 방식 유지 --}}
@else @else
{{-- Mobile Tabs --}} {{-- Mobile Tabs --}}
<div class="subpage-tabs"> <div class="subpage-tabs">
@include('web.partials.subpage-sidenav', [ @include('web.partials.subpage-sidenav', [
'items' => $subnavItems ?? [], 'items' => $subnavItems ?? [],
'active' => $subnavActive ?? null, 'active' => $subnavActive,
'mode' => 'tabs' 'mode' => 'tabs'
]) ])
</div> </div>
@ -119,16 +63,16 @@
<aside class="subpage-side" aria-label="서브메뉴"> <aside class="subpage-side" aria-label="서브메뉴">
@include('web.partials.subpage-sidenav', [ @include('web.partials.subpage-sidenav', [
'items' => $subnavItems ?? [], 'items' => $subnavItems ?? [],
'active' => $subnavActive ?? null, 'active' => $subnavActive,
'mode' => 'side' 'mode' => 'side',
'title' => $isShopPage ? 'CATEGORY' : ($sideTitle ?? null),
'isShop' => $isShopPage
]) ])
</aside> </aside>
@endif @endif
{{-- Main --}}
<main class="subpage-main" id="main-content"> <main class="subpage-main" id="main-content">
<div class="content-card"> <div class="content-card">
{{-- Header --}}
@include('web.partials.subpage-header', [ @include('web.partials.subpage-header', [
'title' => $pageTitle ?? '페이지 제목', 'title' => $pageTitle ?? '페이지 제목',
'desc' => $pageDesc ?? null 'desc' => $pageDesc ?? null
@ -136,7 +80,6 @@
@yield('subcontent') @yield('subcontent')
</div> </div>
</main> </main>
</div> </div>
</div> </div>
</div> </div>

View File

@ -1,21 +1,21 @@
<div class="container"> <div class="container">
<div class="tag-chips" aria-label="추천 태그"> <div class="tag-chips" aria-label="추천 태그">
<a href="/shop?tag=googleplay" class="tag-chip tag-blue"># GooglePlay</a> <a href="/product/list/?tag=googleplay" class="tag-chip tag-blue"># GooglePlay</a>
<a href="/shop?tag=online" class="tag-chip tag-violet"># 온라인상품권</a> <a href="/product/list/?tag=online" class="tag-chip tag-violet"># 온라인상품권</a>
<a href="/shop?tag=game" class="tag-chip tag-green"># 게임상품권</a> <a href="/product/list/?tag=game" class="tag-chip tag-green"># 게임상품권</a>
<a href="/shop?tag=convenience" class="tag-chip tag-sky"># 편의점</a> <a href="/product/list/?tag=convenience" class="tag-chip tag-sky"># 편의점</a>
<a href="/shop?tag=culture" class="tag-chip"># 컬쳐문화상품권</a> <a href="/product/list/?tag=culture" class="tag-chip"># 컬쳐문화상품권</a>
<a href="/shop?tag=nexon" class="tag-chip"># 넥슨온라인게임상품권</a> <a href="/product/list/?tag=nexon" class="tag-chip"># 넥슨온라인게임상품권</a>
<a href="/shop?tag=book" class="tag-chip"># 도서문화상품권</a> <a href="/product/list/?tag=book" class="tag-chip"># 도서문화상품권</a>
<a href="/shop?tag=eggmoney" class="tag-chip"># 에그머니상품권</a> <a href="/product/list/?tag=eggmoney" class="tag-chip"># 에그머니상품권</a>
<a href="/shop?tag=cu" class="tag-chip"># CU모바일상품권</a> <a href="/product/list/?tag=cu" class="tag-chip"># CU모바일상품권</a>
<a href="/shop?tag=itemmania" class="tag-chip"># 아이템매니아</a> <a href="/product/list/?tag=itemmania" class="tag-chip"># 아이템매니아</a>
<a href="/shop?tag=itembay" class="tag-chip"># 아이템베이</a> <a href="/product/list/?tag=itembay" class="tag-chip"># 아이템베이</a>
<a href="/shop?tag=naverwebtoon" class="tag-chip"># 네이버웹툰</a> <a href="/product/list/?tag=naverwebtoon" class="tag-chip"># 네이버웹툰</a>
<a href="/shop?tag=playstation" class="tag-chip"># PlayStation</a> <a href="/product/list/?tag=playstation" class="tag-chip"># PlayStation</a>
<a href="/shop?tag=starballoon" class="tag-chip"># 별풍선교환권</a> <a href="/product/list/?tag=starballoon" class="tag-chip"># 별풍선교환권</a>
<a href="/shop?tag=jopalgye" class="tag-chip"># 저팔계</a> <a href="/product/list/?tag=jopalgye" class="tag-chip"># 저팔계</a>
</div> </div>
</div> </div>

View File

@ -2,54 +2,190 @@
$items = $items ?? []; $items = $items ?? [];
$active = $active ?? null; $active = $active ?? null;
$mode = $mode ?? 'side'; $mode = $mode ?? 'side';
$isShop = $isShop ?? false;
$title = $title ?? null; $title = $title ?? null;
$subtitle = $subtitle ?? null;
// 현재 활성화된 아이템 찾기
$activeItem = collect($items)->first(function($it) use ($active, $isShop) {
if($isShop) return $active == $it['id'];
return $active === ($it['key'] ?? $it['url']);
});
if (!$activeItem && $isShop) {
foreach($items as $it) {
if(!empty($it['children'])) {
$child = collect($it['children'])->firstWhere('id', $active);
if($child) { $activeItem = $child; break; }
}
}
}
@endphp @endphp
@if($mode === 'tabs') @if($mode === 'tabs')
<nav class="subnav subnav--tabs" aria-label="서브메뉴 탭"> {{-- 모바일 전용: 줄에 2개씩 배치되는 그리드 디자인 --}}
<nav class="mobile-subnav-grid" aria-label="모바일 카테고리">
<div class="m-grid-container">
@foreach($items as $it) @foreach($items as $it)
@php $isActive = ($active && $active === ($it['key'] ?? $it['url'])); @endphp @php
$isActive = $isShop ? ($active == $it['id']) : ($active && $active === ($it['key'] ?? $it['url']));
$hasActiveChild = $isShop && !empty($it['children']) && collect($it['children'])->contains('id', $active);
$isOpen = $isActive || $hasActiveChild;
@endphp
<div class="m-grid-item {{ $isOpen ? 'is-open' : '' }}">
<a href="{{ $it['url'] ?? '#' }}" <a href="{{ $it['url'] ?? '#' }}"
class="subnav-tab {{ $isActive ? 'is-active' : '' }}" class="m-grid-link {{ $isOpen ? 'is-active' : '' }}">
@if($isActive) aria-current="page" @endif>
{{ $it['label'] ?? '' }} {{ $it['label'] ?? '' }}
</a> </a>
{{-- 1 메뉴 활성 2 메뉴 노출 (그리드 아래로 펼쳐짐) --}}
@if($isShop && !empty($it['children']) && $isOpen)
<div class="m-grid-sub-wrap">
@foreach($it['children'] as $child)
<a href="{{ $child['url'] }}"
class="m-grid-sub-link {{ $active == $child['id'] ? 'is-active' : '' }}">
{{ $child['label'] }}
</a>
@endforeach @endforeach
</nav> </div>
@else
<nav class="subnav subnav--side" aria-label="서브메뉴">
{{-- 박스 wrapper: 헤더도 박스 안으로 --}}
<div class="subnav-box">
@if($title || $subtitle)
<div class="subnav-head">
@if($title)
<div class="subnav-title">{{ $title }}</div>
@endif
@if($subtitle)
<div class="subnav-subtitle">{{ $subtitle }}</div>
@endif @endif
</div> </div>
<div class="subnav-divider"></div> @endforeach
</div>
</nav>
@else
{{-- 데스크톱 전용: 프리미엄 사이드바 (기존 유지) --}}
<nav class="subnav subnav--side">
<div class="subnav-box premium-design">
@if($title)
<div class="subnav-head">
<div class="subnav-title">{{ $title }}</div>
<div class="subnav-accent"></div>
</div>
@endif @endif
<ul class="subnav-list"> <ul class="subnav-list">
@foreach($items as $it) @foreach($items as $it)
@php $isActive = ($active && $active === ($it['key'] ?? $it['url'])); @endphp @php
$isActive = $isShop ? ($active == $it['id']) : ($active && $active === ($it['key'] ?? $it['url']));
$hasActiveChild = $isShop && !empty($it['children']) && collect($it['children'])->contains('id', $active);
@endphp
<li class="nav-group {{ $isActive || $hasActiveChild ? 'is-open' : '' }}">
<a href="{{ $it['url'] ?? '#' }}" class="nav-main-link {{ $isActive ? 'is-active' : '' }}">
<span class="label-text">{{ $it['label'] ?? '' }}</span>
@if(!empty($it['children']))
<i class="bi bi-chevron-down arrow-icon"></i>
@endif
</a>
@if($isShop && !empty($it['children']))
<ul class="nav-sub-list">
@foreach($it['children'] as $child)
<li> <li>
<a href="{{ $it['url'] ?? '#' }}" <a href="{{ $child['url'] }}"
class="subnav-link {{ $isActive ? 'is-active' : '' }}" class="nav-sub-link {{ $active == $child['id'] ? 'is-active' : '' }}">
@if($isActive) aria-current="page" @endif> {{ $child['label'] }}
{{ $it['label'] ?? '' }}
</a> </a>
</li> </li>
@endforeach @endforeach
</ul> </ul>
@endif
</li>
@endforeach
</ul>
</div> </div>
</nav> </nav>
@endif @endif
<style>
/* --- 모바일 그리드(Grid) 스타일 --- */
.mobile-subnav-grid {
margin-bottom: 25px;
}
.m-grid-container {
display: grid;
grid-template-columns: 1fr 1fr; /* 한 줄에 2개 */
gap: 10px;
}
/* 2차 메뉴가 열릴 때 해당 행 전체를 차지하도록 설정 */
.m-grid-item.is-open {
grid-column: span 2; /* 활성화된 메뉴는 한 줄 전체 차지 (2차 메뉴 가독성) */
}
.m-grid-link {
display: flex;
align-items: center;
justify-content: center;
height: 50px;
background: #f8f9fa;
color: #444;
border-radius: 12px;
font-size: 0.95rem;
font-weight: 600;
text-decoration: none;
border: 1px solid #eee;
transition: all 0.2s;
text-align: center;
}
.m-grid-link.is-active {
background: #007bff;
color: #fff;
border-color: #007bff;
box-shadow: 0 4px 12px rgba(0, 123, 255, 0.2);
}
/* 모바일 2차 메뉴 리스트 */
.m-grid-sub-wrap {
display: grid;
grid-template-columns: 1fr 1fr; /* 2차 메뉴도 2개씩 */
gap: 8px;
background: #f1f3f5;
padding: 12px;
border-radius: 12px;
margin-top: 8px;
}
.m-grid-sub-link {
display: block;
padding: 8px;
font-size: 0.85rem;
color: #666;
text-decoration: none;
background: #fff;
border-radius: 8px;
text-align: center;
border: 1px solid #e9ecef;
}
.m-grid-sub-link.is-active {
color: #007bff;
font-weight: 700;
border-color: #007bff;
}
/* --- 데스크톱 프리미엄 사이드바 (기존 유지) --- */
.premium-design { border: none; background: #fff; box-shadow: 0 4px 20px rgba(0,0,0,0.05); border-radius: 20px; padding: 15px; }
.subnav-head { padding: 10px 15px 15px; }
.subnav-title { font-size: 0.75rem; font-weight: 800; color: #bbb; letter-spacing: 1px; }
.subnav-accent { width: 20px; height: 3px; background: #007bff; margin-top: 6px; border-radius: 2px; }
.nav-main-link {
display: flex; align-items: center; justify-content: space-between;
padding: 12px 16px; color: #444; font-weight: 600; border-radius: 12px; transition: 0.3s; text-decoration: none; margin-bottom: 2px;
}
.nav-main-link:hover { background: #f8f9fa; }
.nav-main-link.is-active { background: #007bff; color: #fff; }
.nav-sub-list { list-style: none; padding: 5px 0 10px 15px; }
.nav-sub-link { display: block; padding: 7px 15px; font-size: 0.85rem; color: #777; text-decoration: none; border-radius: 8px; }
.nav-sub-link.is-active { color: #007bff; background: #f0f7ff; font-weight: 700; }
/* 화면 크기에 따른 노출 제어 */
@media (max-width: 991px) {
.subnav--side { display: none; }
}
@media (min-width: 992px) {
.mobile-subnav-grid { display: none; }
}
</style>

View File

@ -0,0 +1,250 @@
@extends('web.layouts.subpage')
@push('styles')
<style>
/* 전체 레이아웃 50:50 비율 */
.p-detail-container { display: flex; gap: 40px; margin-bottom: 40px; align-items: flex-start; }
.p-detail-visual { flex: 5; }
.p-detail-info { flex: 5; display: flex; flex-direction: column; gap: 24px; }
/* 이미지 카드 */
.p-main-img-card { background: #fff; border: 1px solid #eee; border-radius: 16px; display: flex; align-items: center; justify-content: center; padding: 30px; aspect-ratio: 1/1; margin-bottom: 30px; }
.p-main-img-card img { width: 100%; max-width: 320px; height: auto; filter: drop-shadow(0 10px 20px rgba(0,0,0,0.05)); }
/* 탭 버튼 그룹 (부트스트랩 스타일) */
.p-tab-container { margin-top: 20px; }
.p-btn-group { display: flex; border: 1px solid #dee2e6; border-radius: 8px; overflow: hidden; margin-bottom: 20px; }
.p-tab-btn { flex: 1; padding: 12px; border: none; background: #f8f9fa; color: #495057; font-weight: 700; cursor: pointer; transition: 0.2s; border-right: 1px solid #dee2e6; }
.p-tab-btn:last-child { border-right: none; }
.p-tab-btn.is-active { background: #3182ce; color: #fff; }
.p-tab-content { padding: 20px; background: #fff; border: 1px solid #edf2f7; border-radius: 12px; min-height: 150px; }
.p-content-pane { display: none; font-size: 15px; color: #4a5568; line-height: 1.7; }
.p-content-pane.is-active { display: block; }
/* 권종 선택 (웹: 2줄) */
.p-sku-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 10px; }
.p-sku-btn { background: #fff; border: 1px solid #e2e8f0; border-radius: 12px; padding: 16px; transition: all 0.2s; position: relative; cursor: pointer; text-align: left; }
.p-sku-btn.is-active { border-color: #3182ce; background: #ebf8ff; box-shadow: 0 0 0 1px #3182ce; }
.p-sku-price { font-size: 18px; font-weight: 800; color: #2d3748; }
/* 수량 및 결제 수단 */
.p-qty-wrapper { display: flex; align-items: center; justify-content: space-between; padding: 15px 0; border-top: 1px solid #edf2f7; border-bottom: 1px solid #edf2f7; }
.p-qty-ctrl { display: flex; align-items: center; border: 1px solid #cbd5e0; border-radius: 8px; overflow: hidden; background: #fff; }
.p-qty-btn { width: 44px; height: 44px; border: none; background: #f7fafc; font-size: 20px; cursor: pointer; }
.p-qty-input { width: 60px; height: 44px; border: none; text-align: center; font-weight: 700; font-size: 16px; }
.p-pay-method-grid { display: flex; gap: 8px; flex-wrap: wrap; }
.p-pay-btn { font-size: 12px; background: #fff; border: 1px solid #edf2f7; padding: 8px 14px; border-radius: 8px; color: #4a5568; cursor: pointer; }
.p-pay-btn.is-active { background: #3182ce; color: #fff; border-color: #3182ce; font-weight: 700; }
/* 요약 영역 */
.p-price-summary { margin-top: 30px; padding: 20px; background: #f8fafc; border-radius: 12px; }
.p-total-price { font-size: 28px; font-weight: 900; color: #2b6cb0; }
.p-btn-order-submit { background: #3182ce; color: #fff; width: 100%; height: 60px; border-radius: 12px; font-weight: 700; border: none; font-size: 18px; cursor: pointer; }
/* 모바일 대응 */
@media (max-width: 991px) {
.p-detail-container { flex-direction: column; }
/* 모바일 권종 한 줄 배치 */
.p-sku-grid { grid-template-columns: 1fr; }
/* 모바일 탭 영역 하단 배치용 순서 변경 */
.p-detail-visual { display: flex; flex-direction: column; width: 100%; }
.p-tab-container { order: 10; margin-top: 40px; } /* 결제정보 아래로 이동 */
.p-detail-info { width: 100%; }
}
</style>
@endpush
@section('subcontent')
<div class="p-detail-container">
{{-- 왼쪽 영역 --}}
<div class="p-detail-visual">
<div class="p-main-img-card">
<img src="{{ $product->thumb_path ?? '/assets/images/no-image.png' }}" id="productImg">
</div>
{{-- 상세 정보 그룹 --}}
<div class="p-tab-container">
<div class="p-btn-group">
<button type="button" class="p-tab-btn is-active" onclick="window.switchTab(this, 'desc')">상품설명</button>
<button type="button" class="p-tab-btn" onclick="window.switchTab(this, 'guide')">이용안내</button>
<button type="button" class="p-tab-btn" onclick="window.switchTab(this, 'warning')">주의사항</button>
</div>
<div class="p-tab-content">
<div id="pane-desc" class="p-content-pane is-active">{!! $product->description !!}</div>
<div id="pane-guide" class="p-content-pane">{!! $product->guide !!}</div>
<div id="pane-warning" class="p-content-pane">{!! $product->warning !!}</div>
</div>
</div>
</div>
{{-- 오른쪽 영역 --}}
<div class="p-detail-info">
<div class="p-info-header">
<span style="color:#3182ce; font-weight:700; font-size:14px; letter-spacing:1px;">VERIFIED RESELLER</span>
<h2>{{ $product->name }}</h2>
</div>
<div class="p-order-form">
<div class="p-sku-grid">
@foreach($skus as $sku)
<div class="p-sku-btn @if($loop->first) is-active @endif"
data-sku-id="{{ $sku->id }}"
data-price="{{ $sku->final_price }}"
onclick="window.handleSkuSelect(this)">
@if($sku->discount_value > 0)
<span class="p-sku-badge">{{ $sku->discount_type === 'PERCENT' ? $sku->discount_value.'%' : number_format($sku->discount_value).'원' }}</span>
@endif
<span style="display:block; font-size:13px; color:#a0aec0; margin-bottom:4px;">{{ $sku->name }}</span>
<div style="display:flex; align-items:baseline; gap:8px;">
<span class="p-sku-price">{{ number_format($sku->final_price) }}</span>
@if($sku->discount_value > 0)
<span style="font-size:13px; color:#cbd5e0; text-decoration:line-through;">{{ number_format($sku->face_value) }}</span>
@endif
</div>
</div>
@endforeach
</div>
<div class="p-qty-wrapper" style="margin-top:25px;">
<label class="p-option-title" style="margin-bottom:0;">구매 수량</label>
<div class="p-qty-ctrl">
<button type="button" class="p-qty-btn" onclick="window.changeQty(-1)">-</button>
<input type="text" id="pOrderQty" class="p-qty-input" value="{{ $product->min_buy_qty }}" readonly>
<button type="button" class="p-qty-btn" onclick="window.changeQty(1)">+</button>
</div>
</div>
<div style="margin-top:20px; padding:15px; background:#f7fafc; border-radius:12px;">
<span style="font-size:13px; font-weight:700; color:#4a5568; display:block; margin-bottom:10px;">결제 수단 선택</span>
<div class="p-pay-method-grid">
@foreach($payments as $pay)
<button type="button"
class="p-pay-btn @if($loop->first) is-active @endif"
data-pay-id="{{ $pay->id }}"
data-fee-rate="{{ (float)$pay->customer_fee_rate }}"
onclick="window.handlePaySelect(this)">
{{ $pay->display_name }}
</button>
@endforeach
</div>
</div>
<div class="p-price-summary">
<div class="p-summary-row" style="display:flex; justify-content:space-between; font-size:14px; color:#718096; margin-bottom:8px;">
<span>상품 합계</span>
<span><span id="pBaseTotal">0</span></span>
</div>
<div class="p-summary-row" style="display:flex; justify-content:space-between; font-size:14px; color:#718096; margin-bottom:8px;">
<span>결제 수수료 (<span id="pFeeRateDisplay">0</span>%)</span>
<span>+ <span id="pFeeAmount">0</span></span>
</div>
<div class="p-total-row" style="display:flex; justify-content:space-between; align-items:center; margin-top:10px; padding-top:10px; border-top:1px solid #e2e8f0;">
<span style="font-weight:800; color:#4a5568; font-size:16px;">최종 결제금액</span>
<div class="p-total-price"><span id="pTotalVal">0</span><small style="font-size:18px; margin-left:2px;"></small></div>
</div>
</div>
<div style="margin-top:25px;">
<button type="button" class="p-btn-order-submit" onclick="window.submitOrder()">구매하기</button>
</div>
</div>
</div>
</div>
@endsection
@push('scripts')
<script>
(function() {
const POLICY = {
minQty: parseInt("{{ $product->min_buy_qty }}") || 1,
maxQty: parseInt("{{ $product->max_buy_qty }}") || 999,
maxAmount: parseInt("{{ $product->max_buy_amount }}") || 0
};
let currentUnitPrice = 0;
let currentFeeRate = 0;
// 탭 전환 함수
window.switchTab = function(btn, type) {
document.querySelectorAll('.p-tab-btn').forEach(b => b.classList.remove('is-active'));
btn.classList.add('is-active');
document.querySelectorAll('.p-content-pane').forEach(p => p.classList.remove('is-active'));
document.getElementById('pane-' + type).classList.add('is-active');
};
window.handlePaySelect = function(el) {
document.querySelectorAll('.p-pay-btn').forEach(b => b.classList.remove('is-active'));
el.classList.add('is-active');
currentFeeRate = parseFloat(el.dataset.feeRate);
calculateTotal();
};
window.handleSkuSelect = function(el) {
document.querySelectorAll('.p-sku-btn').forEach(b => b.classList.remove('is-active'));
el.classList.add('is-active');
currentUnitPrice = parseInt(el.dataset.price);
validateAndCalculate();
};
window.changeQty = async function(delta) {
const qtyInput = document.getElementById('pOrderQty');
let nextQty = parseInt(qtyInput.value) + delta;
if (nextQty < POLICY.minQty) return;
if (POLICY.maxQty > 0 && nextQty > POLICY.maxQty) {
await showMsg('최대 구매 수량(' + POLICY.maxQty + '개)을 초과할 수 없습니다.', { type:'alert', title:'수량초과' });
return;
}
if (POLICY.maxAmount > 0 && (currentUnitPrice * nextQty) > POLICY.maxAmount) {
await showMsg('1회 최대 결제 금액 초과할 수 없습니다.', { type:'alert', title:'금액초과' });
return;
}
qtyInput.value = nextQty;
calculateTotal();
};
function validateAndCalculate() {
const qtyInput = document.getElementById('pOrderQty');
let qty = parseInt(qtyInput.value);
if (POLICY.maxAmount > 0 && (currentUnitPrice * qty) > POLICY.maxAmount) {
qty = Math.floor(POLICY.maxAmount / currentUnitPrice);
if(qty < POLICY.minQty) qty = POLICY.minQty;
qtyInput.value = qty;
}
calculateTotal();
}
function calculateTotal() {
const qty = parseInt(document.getElementById('pOrderQty').value);
const baseTotal = currentUnitPrice * qty;
const feeAmount = Math.floor(baseTotal * (currentFeeRate / 100));
document.getElementById('pBaseTotal').innerText = baseTotal.toLocaleString();
document.getElementById('pFeeRateDisplay').innerText = currentFeeRate;
document.getElementById('pFeeAmount').innerText = feeAmount.toLocaleString();
document.getElementById('pTotalVal').innerText = (baseTotal + feeAmount).toLocaleString();
}
window.submitOrder = async function() {
const activeSku = document.querySelector('.p-sku-btn.is-active');
const activePay = document.querySelector('.p-pay-btn.is-active');
const qty = document.getElementById('pOrderQty').value;
if (!activeSku) return await showMsg('권종을 선택해주세요.', { type:'alert', title:'권종선택' });
location.href = "/order/checkout?sku_id=" + activeSku.dataset.skuId + "&qty=" + qty + "&pay_id=" + activePay.dataset.payId;
};
document.addEventListener('DOMContentLoaded', function() {
const firstSku = document.querySelector('.p-sku-btn.is-active');
const firstPay = document.querySelector('.p-pay-btn.is-active');
if (firstSku) currentUnitPrice = parseInt(firstSku.dataset.price);
if (firstPay) currentFeeRate = parseFloat(firstPay.dataset.feeRate);
calculateTotal();
});
})();
</script>
@endpush

View File

@ -0,0 +1,58 @@
@extends('web.layouts.subpage')
@section('subcontent')
<div class="product-scene">
@if(!empty($searchKeyword))
<div class="search-result-info" style="margin-bottom: 20px; padding: 15px; background: #f8f9fa; border-radius: 12px;">
<span style="color: #007bff; font-weight: 700;">"{{ $searchKeyword }}"</span> 대한 검색 결과가 <strong>{{ $products->count() }}</strong> 있습니다.
</div>
@endif
<div class="product-grid">
@forelse($products as $product)
<div class="premium-card">
<a href="{{ route('web.product.show', $product->id) }}">
<div class="img-container">
<img src="{{ $product->thumb_path ?? '/images/no-image.png' }}" alt="{{ $product->name }}">
<div class="img-badge">BEST</div>
</div>
<div class="info-container">
<div class="top-row">
<span class="cat-label">{{ $currentCategoryName ?? 'Premium' }}</span>
<div class="status-dot"></div>
</div>
<h3 class="prod-title">{{ $product->name }}</h3>
<div class="price-box">
<div class="discount-rate">적립가능</div>
<div class="main-price">상세보기 <i class="bi bi-chevron-right"></i></div>
</div>
</div>
</a>
</div>
@empty
<div class="no-results">
<p>현재 판매 준비 중인 상품입니다.</p>
</div>
@endforelse
</div>
</div>
<style>
.product-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); gap: 30px; }
.premium-card { background: #fff; border-radius: 24px; border: 1px solid #f2f2f2; transition: 0.4s cubic-bezier(0.25, 0.46, 0.45, 0.94); overflow: hidden; position: relative; }
.premium-card:hover { transform: translateY(-12px); box-shadow: 0 25px 50px rgba(0,0,0,0.08); border-color: #007bff; }
.img-container { background: #fcfcfc; padding: 40px; aspect-ratio: 1/1; display: flex; align-items: center; justify-content: center; position: relative; }
.img-container img { width: 90%; transition: 0.4s; filter: drop-shadow(0 10px 15px rgba(0,0,0,0.05)); }
.premium-card:hover .img-container img { transform: scale(1.1); }
.img-badge { position: absolute; top: 20px; right: 20px; background: #000; color: #fff; font-size: 0.65rem; font-weight: 800; padding: 4px 10px; border-radius: 30px; }
.info-container { padding: 25px; background: #fff; }
.cat-label { font-size: 0.75rem; color: #007bff; font-weight: 700; opacity: 0.8; }
.prod-title { font-size: 1.1rem; font-weight: 700; color: #111; margin: 8px 0 20px; line-height: 1.4; height: 2.8em; overflow: hidden; }
.price-box { display: flex; justify-content: space-between; align-items: center; }
.discount-rate { font-size: 0.8rem; color: #ff4d4f; font-weight: 700; }
.main-price { font-size: 0.9rem; font-weight: 700; color: #333; }
</style>
@endsection

View File

@ -20,6 +20,12 @@ use App\Http\Controllers\Admin\Log\MemberLoginLogController;
use App\Http\Controllers\Admin\Log\MemberPasswdModifyLogController; use App\Http\Controllers\Admin\Log\MemberPasswdModifyLogController;
use App\Http\Controllers\Admin\Log\MemberAccountLogController; use App\Http\Controllers\Admin\Log\MemberAccountLogController;
use App\Http\Controllers\Admin\Log\MemberDanalAuthTelLogController; use App\Http\Controllers\Admin\Log\MemberDanalAuthTelLogController;
use App\Http\Controllers\Admin\Product\AdminCategoryController;
use App\Http\Controllers\Admin\Product\AdminFeeController;
use App\Http\Controllers\Admin\Product\AdminSaleCodeController;
use App\Http\Controllers\Admin\Product\AdminMediaController;
use App\Http\Controllers\Admin\Product\AdminProductController;
use App\Http\Controllers\Admin\Product\AdminPinController;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
Route::middleware(['web'])->group(function () { Route::middleware(['web'])->group(function () {
@ -274,6 +280,75 @@ Route::middleware(['web'])->group(function () {
->name('member-danalauthtel-logs'); ->name('member-danalauthtel-logs');
}); });
Route::prefix('categories')->name('admin.categories.')
->middleware('admin.role:super_admin,product')
->group(function () {
Route::get('/', [AdminCategoryController::class, 'index'])->name('index');
Route::post('/', [AdminCategoryController::class, 'store'])->name('store');
Route::put('/{id}', [AdminCategoryController::class, 'update'])->whereNumber('id')->name('update');
Route::post('/sort', [AdminCategoryController::class, 'updateSort'])->name('sort');
Route::delete('/{id}', [AdminCategoryController::class, 'destroy'])->whereNumber('id')->name('destroy');
});
Route::prefix('fees')->name('admin.fees.')
->middleware('admin.role:super_admin,product')
->group(function () {
// 통합 화면
Route::get('/', [AdminFeeController::class, 'index'])->name('index');
// 매입(출금) 정책 수정
Route::put('/buyback', [AdminFeeController::class, 'updateBuybackPolicy'])->name('buyback.update');
// 결제 수단 (Payment) CRUD & 정렬
Route::post('/payment', [AdminFeeController::class, 'storePayment'])->name('payment.store');
Route::put('/payment/{id}', [AdminFeeController::class, 'updatePayment'])->whereNumber('id')->name('payment.update');
Route::delete('/payment/{id}', [AdminFeeController::class, 'destroyPayment'])->whereNumber('id')->name('payment.destroy');
Route::post('/payment/sort', [AdminFeeController::class, 'updatePaymentSort'])->name('payment.sort');
});
Route::prefix('sale-codes')->name('admin.sale-codes.')
->middleware('admin.role:super_admin,product')
->group(function () {
Route::get('/', [AdminSaleCodeController::class, 'index'])->name('index');
// 연동사(Provider) 라우트
Route::post('/provider', [AdminSaleCodeController::class, 'storeProvider'])->name('provider.store');
Route::put('/provider/{id}', [AdminSaleCodeController::class, 'updateProvider'])->whereNumber('id')->name('provider.update');
Route::delete('/provider/{id}', [AdminSaleCodeController::class, 'destroyProvider'])->whereNumber('id')->name('provider.destroy');
// 상품 코드(Code) 라우트 및 정렬
Route::post('/code', [AdminSaleCodeController::class, 'storeCode'])->name('code.store');
Route::put('/code/{id}', [AdminSaleCodeController::class, 'updateCode'])->whereNumber('id')->name('code.update');
Route::delete('/code/{id}', [AdminSaleCodeController::class, 'destroyCode'])->whereNumber('id')->name('code.destroy');
Route::post('/code/sort', [AdminSaleCodeController::class, 'updateCodeSort'])->name('code.sort');
});
Route::prefix('media')->name('admin.media.')->middleware('admin.role:super_admin,product')->group(function () {
Route::get('/', [AdminMediaController::class, 'index'])->name('index');
Route::post('/', [AdminMediaController::class, 'store'])->name('store');
Route::delete('/{id}', [AdminMediaController::class, 'destroy'])->whereNumber('id')->name('destroy');
// 향후 상품 등록 폼의 팝업 모달에서 AJAX로 호출할 API
Route::get('/api/list', [AdminMediaController::class, 'apiList'])->name('api.list');
// 기존 미디어 라우트 그룹 내부에 추가
Route::put('/{id}/name', [AdminMediaController::class, 'updateName'])->whereNumber('id')->name('name.update');
});
Route::prefix('products')->name('admin.products.')->middleware('admin.role:super_admin,product')->group(function () {
Route::get('/', [AdminProductController::class, 'index'])->name('index');
Route::get('/create', [AdminProductController::class, 'create'])->name('create');
Route::post('/', [AdminProductController::class, 'store'])->name('store');
Route::get('/{id}/edit', [AdminProductController::class, 'edit'])->whereNumber('id')->name('edit');
Route::put('/{id}', [AdminProductController::class, 'update'])->whereNumber('id')->name('update');
});
Route::prefix('products/{productId}/skus/{skuId}/pins')->name('admin.pins.')->middleware('admin.role:super_admin,product')->group(function () {
Route::get('/', [AdminPinController::class, 'index'])->name('index');
Route::post('/bulk', [AdminPinController::class, 'storeBulk'])->name('storeBulk');
Route::post('/recall', [AdminPinController::class, 'recallBulk'])->name('recallBulk');
});
}); });
}); });

View File

@ -10,6 +10,7 @@ use App\Http\Controllers\Web\Mypage\InfoGateController;
use App\Http\Controllers\Web\Cs\NoticeController; use App\Http\Controllers\Web\Cs\NoticeController;
use App\Http\Controllers\Web\Auth\EmailVerificationController; use App\Http\Controllers\Web\Auth\EmailVerificationController;
use App\Http\Controllers\Web\Cs\CsQnaController; use App\Http\Controllers\Web\Cs\CsQnaController;
use App\Http\Controllers\Web\Product\ProductController;
use App\Http\Controllers\Web\Mypage\MypageQnaController; use App\Http\Controllers\Web\Mypage\MypageQnaController;
@ -144,6 +145,11 @@ Route::prefix('auth')->name('web.auth.')->group(function () {
}); });
}); });
Route::group(['prefix' => 'product', 'as' => 'web.product.'], function () {
Route::get('/list/{category?}', [App\Http\Controllers\Web\Product\ProductController::class, 'index'])->name('index');
Route::get('/detail/{id}', [App\Http\Controllers\Web\Product\ProductController::class, 'show'])->name('show');
});
/* /*
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
| Legacy redirects | Legacy redirects