관리자 상품관리 완료
웹사이트 상품리스트 상세보기 작업중
This commit is contained in:
parent
6e8e8b5a57
commit
b0545ab5b9
118
app/Http/Controllers/Admin/Product/AdminCategoryController.php
Normal file
118
app/Http/Controllers/Admin/Product/AdminCategoryController.php
Normal 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);
|
||||
}
|
||||
}
|
||||
97
app/Http/Controllers/Admin/Product/AdminFeeController.php
Normal file
97
app/Http/Controllers/Admin/Product/AdminFeeController.php
Normal 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'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
89
app/Http/Controllers/Admin/Product/AdminMediaController.php
Normal file
89
app/Http/Controllers/Admin/Product/AdminMediaController.php
Normal 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);
|
||||
}
|
||||
}
|
||||
92
app/Http/Controllers/Admin/Product/AdminPinController.php
Normal file
92
app/Http/Controllers/Admin/Product/AdminPinController.php
Normal 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']]);
|
||||
}
|
||||
}
|
||||
109
app/Http/Controllers/Admin/Product/AdminProductController.php
Normal file
109
app/Http/Controllers/Admin/Product/AdminProductController.php
Normal 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']]);
|
||||
}
|
||||
|
||||
}
|
||||
@ -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']]);
|
||||
}
|
||||
}
|
||||
42
app/Http/Controllers/Web/Product/ProductController.php
Normal file
42
app/Http/Controllers/Web/Product/ProductController.php
Normal 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
69
app/Repositories/Admin/Product/AdminCategoryRepository.php
Normal file
69
app/Repositories/Admin/Product/AdminCategoryRepository.php
Normal 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;
|
||||
}
|
||||
}
|
||||
65
app/Repositories/Admin/Product/AdminFeeRepository.php
Normal file
65
app/Repositories/Admin/Product/AdminFeeRepository.php
Normal 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;
|
||||
}
|
||||
}
|
||||
64
app/Repositories/Admin/Product/AdminMediaRepository.php
Normal file
64
app/Repositories/Admin/Product/AdminMediaRepository.php
Normal 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;
|
||||
}
|
||||
}
|
||||
55
app/Repositories/Admin/Product/AdminPinRepository.php
Normal file
55
app/Repositories/Admin/Product/AdminPinRepository.php
Normal 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);
|
||||
}
|
||||
}
|
||||
148
app/Repositories/Admin/Product/AdminProductRepository.php
Normal file
148
app/Repositories/Admin/Product/AdminProductRepository.php
Normal 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;
|
||||
}
|
||||
}
|
||||
91
app/Repositories/Admin/Product/AdminSaleCodeRepository.php
Normal file
91
app/Repositories/Admin/Product/AdminSaleCodeRepository.php
Normal 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();
|
||||
}
|
||||
}
|
||||
111
app/Repositories/Product/ProductRepository.php
Normal file
111
app/Repositories/Product/ProductRepository.php
Normal 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();
|
||||
}
|
||||
}
|
||||
156
app/Services/Admin/Product/AdminCategoryService.php
Normal file
156
app/Services/Admin/Product/AdminCategoryService.php
Normal 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' => '정렬 중 오류가 발생했습니다.'];
|
||||
}
|
||||
}
|
||||
}
|
||||
127
app/Services/Admin/Product/AdminFeeService.php
Normal file
127
app/Services/Admin/Product/AdminFeeService.php
Normal 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' => '삭제되었습니다.'];
|
||||
}
|
||||
}
|
||||
189
app/Services/Admin/Product/AdminMediaService.php
Normal file
189
app/Services/Admin/Product/AdminMediaService.php
Normal 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' => '삭제 처리 중 오류가 발생했습니다.'];
|
||||
}
|
||||
}
|
||||
}
|
||||
169
app/Services/Admin/Product/AdminPinService.php
Normal file
169
app/Services/Admin/Product/AdminPinService.php
Normal 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()];
|
||||
}
|
||||
}
|
||||
}
|
||||
226
app/Services/Admin/Product/AdminProductService.php
Normal file
226
app/Services/Admin/Product/AdminProductService.php
Normal 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()];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
169
app/Services/Admin/Product/AdminSaleCodeService.php
Normal file
169
app/Services/Admin/Product/AdminSaleCodeService.php
Normal 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' => '상품 코드가 삭제되었습니다.'];
|
||||
}
|
||||
}
|
||||
75
app/Services/Product/ProductService.php
Normal file
75
app/Services/Product/ProductService.php
Normal 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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -7,6 +7,7 @@
|
||||
"license": "MIT",
|
||||
"require": {
|
||||
"php": "^8.2",
|
||||
"intervention/image": "^3.11",
|
||||
"laravel/fortify": "^1.34",
|
||||
"laravel/framework": "^12.0",
|
||||
"laravel/tinker": "^2.10.1",
|
||||
|
||||
146
composer.lock
generated
146
composer.lock
generated
@ -4,7 +4,7 @@
|
||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "5489ef2cd1c47137632ceb06cdbab30b",
|
||||
"content-hash": "0806303423926715aa8f817719c01f46",
|
||||
"packages": [
|
||||
{
|
||||
"name": "bacon/bacon-qr-code",
|
||||
@ -1218,6 +1218,150 @@
|
||||
],
|
||||
"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",
|
||||
"version": "v1.34.1",
|
||||
|
||||
@ -7,7 +7,7 @@ return [
|
||||
'title' => '안전하고 빠른 상품권 거래',
|
||||
'desc' => '가장 저렴한 온라인 상품권 판매. 구글플레이·문화상품권·편의점 등 인기 상품을 할인 구매하세요.',
|
||||
'cta_label' => '상품 보러가기',
|
||||
'cta_url' => '/shop',
|
||||
'cta_url' => '/product/list',
|
||||
'image' => [
|
||||
'default' => '/assets/images/common/hero/hero-01-shop.webp',
|
||||
'mobile' => '/assets/images/common/hero/hero-01-shop.webp',
|
||||
@ -19,7 +19,7 @@ return [
|
||||
'title' => '카드/휴대폰 결제 지원',
|
||||
'desc' => '원하는 결제수단으로 편하게 결제하고, 발송은 빠르게 받아보세요.',
|
||||
'cta_label' => '상품 보러가기',
|
||||
'cta_url' => '/shop',
|
||||
'cta_url' => '/product/list',
|
||||
'image' => [
|
||||
'default' => '/assets/images/common/hero/hero-02-pay.webp',
|
||||
'mobile' => '/assets/images/common/hero/hero-02-pay.webp',
|
||||
|
||||
@ -124,26 +124,38 @@ CREATE TABLE IF NOT EXISTS pfy_product_contents (
|
||||
- 재고는 `stock_mode` 로 (연동판매는 `infinite`, 자사핀은 `limited`)
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS pfy_product_skus (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT 'PK',
|
||||
product_id BIGINT UNSIGNED NOT NULL COMMENT '상품 ID(pfy_products.id)',
|
||||
denomination INT UNSIGNED NOT NULL COMMENT '권종 금액(예: 10000, 50000)',
|
||||
normal_price INT UNSIGNED NOT NULL COMMENT '정상가(원)',
|
||||
discount_rate DECIMAL(5,2) NOT NULL DEFAULT 0.00 COMMENT '할인율(%)',
|
||||
sale_price INT UNSIGNED NOT NULL COMMENT '판매가(원) - 운영/정산 안정 위해 계산 후 저장 권장',
|
||||
stock_mode ENUM('infinite','limited') NOT NULL DEFAULT 'infinite' COMMENT '재고방식(무한/한정)',
|
||||
is_active TINYINT(1) NOT NULL DEFAULT 1 COMMENT '사용여부(1=사용,0=중지)',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '생성일',
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '수정일',
|
||||
PRIMARY KEY (id),
|
||||
KEY idx_pfy_skus_product (product_id),
|
||||
KEY idx_pfy_skus_active (product_id, is_active),
|
||||
KEY idx_pfy_skus_denomination (product_id, denomination),
|
||||
CONSTRAINT fk_pfy_skus_product
|
||||
FOREIGN KEY (product_id) REFERENCES pfy_products(id)
|
||||
ON UPDATE CASCADE ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
COMMENT='[PFY] 상품 SKU(권종/가격 단위)';
|
||||
CREATE TABLE `pfy_product_skus` (
|
||||
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT 'PK',
|
||||
`product_id` bigint(20) unsigned NOT NULL COMMENT '상품 ID (pfy_products.id)',
|
||||
|
||||
`sku_code` varchar(60) DEFAULT NULL COMMENT '내부 SKU 코드(선택). 운영상 필요 시 사용',
|
||||
|
||||
`face_value` int(10) unsigned NOT NULL COMMENT '권면가(원) 예: 10000',
|
||||
`normal_price` int(10) unsigned NOT NULL COMMENT '정상가(원)',
|
||||
`discount_rate` decimal(5,2) NOT NULL DEFAULT 0.00 COMMENT '할인율(%) 예: 3.50',
|
||||
`sale_price` int(10) unsigned NOT NULL COMMENT '판매가(원) = floor(normal_price*(100-discount_rate)/100)',
|
||||
|
||||
`status` enum('active','hidden') NOT NULL DEFAULT 'active' COMMENT '노출상태(active=노출, hidden=숨김)',
|
||||
`sort` int(10) unsigned NOT NULL DEFAULT 0 COMMENT '정렬(작을수록 우선)',
|
||||
|
||||
`created_admin_id` bigint(20) unsigned DEFAULT NULL COMMENT '등록 관리자 ID',
|
||||
`updated_admin_id` bigint(20) unsigned DEFAULT NULL COMMENT '수정 관리자 ID',
|
||||
|
||||
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '생성일시',
|
||||
`updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '수정일시',
|
||||
|
||||
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(금액권/가격)';
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@ -18,6 +18,8 @@
|
||||
.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);}
|
||||
|
||||
.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;}
|
||||
|
||||
@ -18,6 +18,8 @@
|
||||
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--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);
|
||||
font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;font-size:12px;display:inline-block;}
|
||||
|
||||
@ -18,6 +18,8 @@
|
||||
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--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);
|
||||
font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;font-size:12px;display:inline-block;}
|
||||
|
||||
@ -48,31 +48,11 @@
|
||||
[
|
||||
'title' => '상품 관리',
|
||||
'items' => [
|
||||
// 1) 기본 데이터(전시 구조)
|
||||
['label' => '카테고리 관리', 'route' => 'admin.categories.index', 'roles' => ['super_admin','product']], // ✅ 추천(신규)
|
||||
|
||||
// 2) 상품 등록/관리
|
||||
['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']],
|
||||
['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']],
|
||||
['label' => '상품 이미지 라이브러리 관리', 'route' => 'admin.media.index', 'roles' => ['super_admin','product']],
|
||||
['label' => '판매 상품등록', 'route' => 'admin.products.index', 'roles' => ['super_admin','product']],
|
||||
],
|
||||
],
|
||||
[
|
||||
|
||||
239
resources/views/admin/product/category/index.blade.php
Normal file
239
resources/views/admin/product/category/index.blade.php
Normal 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
|
||||
187
resources/views/admin/product/fee/index.blade.php
Normal file
187
resources/views/admin/product/fee/index.blade.php
Normal 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
|
||||
191
resources/views/admin/product/media/index.blade.php
Normal file
191
resources/views/admin/product/media/index.blade.php
Normal 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
|
||||
203
resources/views/admin/product/pins/index.blade.php
Normal file
203
resources/views/admin/product/pins/index.blade.php
Normal 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개) 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
|
||||
560
resources/views/admin/product/products/create.blade.php
Normal file
560
resources/views/admin/product/products/create.blade.php
Normal 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' : '' }}> └ {{ $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
|
||||
605
resources/views/admin/product/products/edit.blade.php
Normal file
605
resources/views/admin/product/products/edit.blade.php
Normal 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' : '' }}> └ {{ $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
|
||||
158
resources/views/admin/product/products/index.blade.php
Normal file
158
resources/views/admin/product/products/index.blade.php
Normal 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' : '' }}> └ {{ $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
|
||||
230
resources/views/admin/product/salecode/index.blade.php
Normal file
230
resources/views/admin/product/salecode/index.blade.php
Normal 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
|
||||
168
resources/views/admin/skus/create.blade.php
Normal file
168
resources/views/admin/skus/create.blade.php
Normal 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
|
||||
237
resources/views/admin/skus/edit.blade.php
Normal file
237
resources/views/admin/skus/edit.blade.php
Normal 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
|
||||
268
resources/views/admin/skus/index.blade.php
Normal file
268
resources/views/admin/skus/index.blade.php
Normal 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
|
||||
@ -19,7 +19,7 @@
|
||||
|
||||
<nav class="desktop-nav" aria-label="주요 메뉴">
|
||||
<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="/mypage/info" class="nav-link">마이페이지</a>
|
||||
<a href="/cs/notice" class="nav-link">고객센터</a>
|
||||
@ -28,7 +28,7 @@
|
||||
|
||||
<!-- Center: Search (Desktop) -->
|
||||
<div class="search-bar">
|
||||
<form action="/shop" method="GET">
|
||||
<form action="/product/list" method="GET">
|
||||
<input type="text" name="search" class="search-input" placeholder="상품권/브랜드 검색">
|
||||
<button type="submit" class="search-icon" aria-label="검색">
|
||||
<svg width="20" height="20" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@ -44,7 +44,7 @@
|
||||
@if($isLogin)
|
||||
|
||||
{{-- 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>
|
||||
|
||||
|
||||
@ -4,7 +4,7 @@
|
||||
<div class="subpage-wrap">
|
||||
<div class="container">
|
||||
|
||||
{{-- Breadcrumb (optional) --}}
|
||||
{{-- Breadcrumb --}}
|
||||
@isset($breadcrumbs)
|
||||
@include('web.partials.breadcrumb', ['items' => $breadcrumbs])
|
||||
@endisset
|
||||
@ -17,100 +17,44 @@
|
||||
$isMypagePage = request()->is('mypage') || request()->is('mypage/*') || request()->routeIs('web.mypage.*');
|
||||
$isPolicyPage = request()->is('policy') || request()->is('policy/*') || request()->routeIs('web.policy.*');
|
||||
|
||||
// [추가] 상품 페이지 섹션 감지
|
||||
$isShopPage = request()->is('product') || request()->is('product/*') || request()->routeIs('web.product.*');
|
||||
|
||||
// CS subnav 자동 주입
|
||||
if ($isCsPage && empty($subnavItems)) {
|
||||
$subnavItems = collect(config('web.cs_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();
|
||||
$subnavItems = collect(config('web.cs_tabs', []))->map(function ($t) {
|
||||
return ['label' => $t['label'] ?? '', 'url' => Route::has($t['route'] ?? '') ? route($t['route']) : '#', 'key' => $t['key'] ?? null];
|
||||
})->all();
|
||||
}
|
||||
|
||||
// MYPAGE subnav 자동 주입
|
||||
if ($isMypagePage && empty($subnavItems)) {
|
||||
$subnavItems = collect(config('web.mypage_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();
|
||||
$subnavItems = collect(config('web.mypage_tabs', []))->map(function ($t) {
|
||||
return ['label' => $t['label'] ?? '', 'url' => Route::has($t['route'] ?? '') ? route($t['route']) : '#', 'key' => $t['key'] ?? null];
|
||||
})->all();
|
||||
}
|
||||
|
||||
// PolicyPage subnav 자동 주입
|
||||
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
|
||||
// active key 설정
|
||||
$subnavActive = $csActive ?? $mypageActive ?? ($subnavActive ?? null);
|
||||
@endphp
|
||||
|
||||
@if(!empty($heroSlides))
|
||||
@include('web.partials.hero-slider', [
|
||||
'slides' => $heroSlides,
|
||||
'variant' => 'compact',
|
||||
'id' => 'hero-sub'
|
||||
])
|
||||
@include('web.partials.hero-slider', ['slides' => $heroSlides, 'variant' => 'compact', 'id' => 'hero-sub'])
|
||||
@endif
|
||||
|
||||
{{-- Body --}}
|
||||
<div class="subpage-grid">
|
||||
|
||||
{{-- 고객센터 cs-tabs --}}
|
||||
@if($isCsPage)
|
||||
@include('web.partials.cs-tabs', [
|
||||
'activeKey' => $subnavActive
|
||||
])
|
||||
|
||||
{{-- 마이페이지 mypage-tabs --}}
|
||||
@include('web.partials.cs-tabs', ['activeKey' => $subnavActive])
|
||||
@elseif($isMypagePage)
|
||||
@include('web.partials.mypage-tabs', [
|
||||
'activeKey' => $subnavActive
|
||||
])
|
||||
|
||||
{{-- pinforyou mypage-tabs --}}
|
||||
@include('web.partials.mypage-tabs', ['activeKey' => $subnavActive])
|
||||
@elseif($isPolicyPage)
|
||||
@include('web.partials.policy-tabs', [
|
||||
'activeKey' => $subnavActive
|
||||
])
|
||||
|
||||
{{-- 그 외 일반 서브페이지는 기존 방식 유지 --}}
|
||||
@include('web.partials.policy-tabs', ['activeKey' => $subnavActive])
|
||||
@else
|
||||
{{-- Mobile Tabs --}}
|
||||
<div class="subpage-tabs">
|
||||
@include('web.partials.subpage-sidenav', [
|
||||
'items' => $subnavItems ?? [],
|
||||
'active' => $subnavActive ?? null,
|
||||
'active' => $subnavActive,
|
||||
'mode' => 'tabs'
|
||||
])
|
||||
</div>
|
||||
@ -119,16 +63,16 @@
|
||||
<aside class="subpage-side" aria-label="서브메뉴">
|
||||
@include('web.partials.subpage-sidenav', [
|
||||
'items' => $subnavItems ?? [],
|
||||
'active' => $subnavActive ?? null,
|
||||
'mode' => 'side'
|
||||
'active' => $subnavActive,
|
||||
'mode' => 'side',
|
||||
'title' => $isShopPage ? 'CATEGORY' : ($sideTitle ?? null),
|
||||
'isShop' => $isShopPage
|
||||
])
|
||||
</aside>
|
||||
@endif
|
||||
|
||||
{{-- Main --}}
|
||||
<main class="subpage-main" id="main-content">
|
||||
<div class="content-card">
|
||||
{{-- Header --}}
|
||||
@include('web.partials.subpage-header', [
|
||||
'title' => $pageTitle ?? '페이지 제목',
|
||||
'desc' => $pageDesc ?? null
|
||||
@ -136,7 +80,6 @@
|
||||
@yield('subcontent')
|
||||
</div>
|
||||
</main>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -1,21 +1,21 @@
|
||||
<div class="container">
|
||||
<div class="tag-chips" aria-label="추천 태그">
|
||||
<a href="/shop?tag=googleplay" class="tag-chip tag-blue"># GooglePlay</a>
|
||||
<a href="/shop?tag=online" class="tag-chip tag-violet"># 온라인상품권</a>
|
||||
<a href="/shop?tag=game" class="tag-chip tag-green"># 게임상품권</a>
|
||||
<a href="/shop?tag=convenience" class="tag-chip tag-sky"># 편의점</a>
|
||||
<a href="/product/list/?tag=googleplay" class="tag-chip tag-blue"># GooglePlay</a>
|
||||
<a href="/product/list/?tag=online" class="tag-chip tag-violet"># 온라인상품권</a>
|
||||
<a href="/product/list/?tag=game" class="tag-chip tag-green"># 게임상품권</a>
|
||||
<a href="/product/list/?tag=convenience" class="tag-chip tag-sky"># 편의점</a>
|
||||
|
||||
<a href="/shop?tag=culture" class="tag-chip"># 컬쳐문화상품권</a>
|
||||
<a href="/shop?tag=nexon" class="tag-chip"># 넥슨온라인게임상품권</a>
|
||||
<a href="/shop?tag=book" class="tag-chip"># 도서문화상품권</a>
|
||||
<a href="/shop?tag=eggmoney" class="tag-chip"># 에그머니상품권</a>
|
||||
<a href="/shop?tag=cu" class="tag-chip"># CU모바일상품권</a>
|
||||
<a href="/product/list/?tag=culture" class="tag-chip"># 컬쳐문화상품권</a>
|
||||
<a href="/product/list/?tag=nexon" class="tag-chip"># 넥슨온라인게임상품권</a>
|
||||
<a href="/product/list/?tag=book" class="tag-chip"># 도서문화상품권</a>
|
||||
<a href="/product/list/?tag=eggmoney" class="tag-chip"># 에그머니상품권</a>
|
||||
<a href="/product/list/?tag=cu" class="tag-chip"># CU모바일상품권</a>
|
||||
|
||||
<a href="/shop?tag=itemmania" class="tag-chip"># 아이템매니아</a>
|
||||
<a href="/shop?tag=itembay" class="tag-chip"># 아이템베이</a>
|
||||
<a href="/shop?tag=naverwebtoon" class="tag-chip"># 네이버웹툰</a>
|
||||
<a href="/shop?tag=playstation" class="tag-chip"># PlayStation</a>
|
||||
<a href="/shop?tag=starballoon" class="tag-chip"># 별풍선교환권</a>
|
||||
<a href="/shop?tag=jopalgye" class="tag-chip"># 저팔계</a>
|
||||
<a href="/product/list/?tag=itemmania" class="tag-chip"># 아이템매니아</a>
|
||||
<a href="/product/list/?tag=itembay" class="tag-chip"># 아이템베이</a>
|
||||
<a href="/product/list/?tag=naverwebtoon" class="tag-chip"># 네이버웹툰</a>
|
||||
<a href="/product/list/?tag=playstation" class="tag-chip"># PlayStation</a>
|
||||
<a href="/product/list/?tag=starballoon" class="tag-chip"># 별풍선교환권</a>
|
||||
<a href="/product/list/?tag=jopalgye" class="tag-chip"># 저팔계</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -2,54 +2,190 @@
|
||||
$items = $items ?? [];
|
||||
$active = $active ?? null;
|
||||
$mode = $mode ?? 'side';
|
||||
|
||||
$isShop = $isShop ?? false;
|
||||
$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
|
||||
|
||||
@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)
|
||||
@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'] ?? '#' }}"
|
||||
class="subnav-tab {{ $isActive ? 'is-active' : '' }}"
|
||||
@if($isActive) aria-current="page" @endif>
|
||||
class="m-grid-link {{ $isOpen ? 'is-active' : '' }}">
|
||||
{{ $it['label'] ?? '' }}
|
||||
</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
|
||||
</nav>
|
||||
@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>
|
||||
</div>
|
||||
@endif
|
||||
</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
|
||||
|
||||
<ul class="subnav-list">
|
||||
@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>
|
||||
<a href="{{ $it['url'] ?? '#' }}"
|
||||
class="subnav-link {{ $isActive ? 'is-active' : '' }}"
|
||||
@if($isActive) aria-current="page" @endif>
|
||||
{{ $it['label'] ?? '' }}
|
||||
<a href="{{ $child['url'] }}"
|
||||
class="nav-sub-link {{ $active == $child['id'] ? 'is-active' : '' }}">
|
||||
{{ $child['label'] }}
|
||||
</a>
|
||||
</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
|
||||
@endif
|
||||
</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
</div>
|
||||
</nav>
|
||||
@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>
|
||||
|
||||
250
resources/views/web/product/detail/index.blade.php
Normal file
250
resources/views/web/product/detail/index.blade.php
Normal 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
|
||||
58
resources/views/web/product/list/index.blade.php
Normal file
58
resources/views/web/product/list/index.blade.php
Normal 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
|
||||
@ -20,6 +20,12 @@ use App\Http\Controllers\Admin\Log\MemberLoginLogController;
|
||||
use App\Http\Controllers\Admin\Log\MemberPasswdModifyLogController;
|
||||
use App\Http\Controllers\Admin\Log\MemberAccountLogController;
|
||||
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;
|
||||
|
||||
Route::middleware(['web'])->group(function () {
|
||||
@ -274,6 +280,75 @@ Route::middleware(['web'])->group(function () {
|
||||
->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');
|
||||
});
|
||||
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@ -10,6 +10,7 @@ use App\Http\Controllers\Web\Mypage\InfoGateController;
|
||||
use App\Http\Controllers\Web\Cs\NoticeController;
|
||||
use App\Http\Controllers\Web\Auth\EmailVerificationController;
|
||||
use App\Http\Controllers\Web\Cs\CsQnaController;
|
||||
use App\Http\Controllers\Web\Product\ProductController;
|
||||
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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user