[FEAT] 新增商品 Excel 批量匯入與模板下載功能

1. 新增 ProductImportService 服務,實作 Excel 商品檔案解析、欄位驗證、多租戶 company_id 歸屬處理及資料批量寫入/更新邏輯。
2. 在 ProductController 新增 import 與 downloadTemplate 動作,處理檔案接收驗證與匯出,並在匯入後自動重建該公司商品目錄快取。
3. routes/web.php 新增商品下載模板與 Excel 匯入的專用路由。
4. 重構 products/index.blade.php,新增極簡奢華風商品匯入 Modal、檔案拖放/選擇區,並採用 Alpine.js 進行彈窗開啟與非同步上傳 API 交互。
5. 擴充 components/page-header.blade.php,使頁面主標頭支援渲染多個操作按鈕 (actions 插槽),以配合新增的「匯入商品」按鈕。
6. 同步更新 lang/zh_TW.json 與 lang/en.json,對齊「商品匯入成功」、「請選擇公司」等相關語系翻譯。
7. 微調 AdvertisementController,維持全站 Controller 進度對齊。
This commit is contained in:
sky121113 2026-05-28 11:19:00 +08:00
parent b0fc0e6520
commit c3e60b8746
9 changed files with 632 additions and 18 deletions

View File

@ -19,6 +19,9 @@ class AdvertisementController extends AdminController
{
$user = auth()->user();
$tab = $request->input('tab', 'list');
$perPage = in_array((int) $request->input('per_page', 10), [10, 25, 50, 100], true)
? (int) $request->input('per_page', 10)
: 10;
// Tab 1: 廣告列表
$query = Advertisement::with('company');
@ -36,7 +39,7 @@ class AdvertisementController extends AdminController
$query->where('company_id', $request->company_id);
}
$advertisements = $query->latest()->paginate(10);
$advertisements = $query->latest()->paginate($perPage)->withQueryString();
// Tab 2: 機台廣告設置 (所需資料) - 隱藏已過期的廣告
$allAds = Advertisement::playing()->get();

View File

@ -13,6 +13,7 @@ use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use App\Services\Product\ProductCatalogService;
use App\Services\ProductImportService;
use App\Models\System\SystemOperationLog;
class ProductController extends Controller
@ -87,6 +88,7 @@ class ProductController extends Controller
'success' => true,
'html' => view('admin.products.partials.tab-products', [
'products' => $products,
'categories' => $categories,
'companySettings' => $companySettings,
'companies' => $companies,
'routeName' => $routeName
@ -540,6 +542,44 @@ class ProductController extends Controller
}
}
public function downloadTemplate(ProductImportService $importService)
{
return $importService->downloadTemplate();
}
public function import(Request $request, ProductImportService $importService)
{
$request->validate([
'file' => 'required|file|mimes:xlsx|max:5120',
'company_id' => 'nullable|exists:companies,id',
]);
$companyId = $request->get('company_id') ?: auth()->user()->company_id;
if (auth()->user()->isSystemAdmin() && !$companyId) {
return response()->json([
'success' => false,
'message' => __('Please select a company'),
], 422);
}
$results = $importService->import(
$request->file('file')->getRealPath(),
$companyId,
auth()->id()
);
foreach ($results['company_ids'] as $importedCompanyId) {
$this->catalogService->rebuildCache($importedCompanyId);
}
return response()->json([
'success' => true,
'message' => __(':count products imported successfully', ['count' => $results['success_count']]),
'results' => $results,
]);
}
/**
* 同步商品目錄至全公司機台 (Phase 2)
*/

View File

@ -0,0 +1,348 @@
<?php
namespace App\Services;
use App\Models\Product\Product;
use App\Models\Product\ProductCategory;
use App\Models\System\SystemOperationLog;
use App\Models\System\Translation;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str;
use OpenSpout\Reader\XLSX\Reader;
use OpenSpout\Writer\XLSX\Writer;
use OpenSpout\Common\Entity\Row;
class ProductImportService
{
public function import(string $filePath, ?int $companyId = null, ?int $userId = null): array
{
$reader = new Reader();
$reader->open($filePath);
$results = [
'success_count' => 0,
'created_count' => 0,
'updated_count' => 0,
'error_count' => 0,
'errors' => [],
'company_ids' => [],
];
$rowCount = 0;
foreach ($reader->getSheetIterator() as $sheet) {
foreach ($sheet->getRowIterator() as $row) {
$rowCount++;
if ($rowCount === 1) {
continue;
}
$cells = $row->toArray();
$data = $this->normalizeRow($cells, $companyId);
if ($this->isEmptyRow($data)) {
continue;
}
$validator = Validator::make($data, [
'company_id' => 'nullable|exists:companies,id',
'category' => 'nullable|string|max:255',
'name_zh_TW' => 'required|string|max:255',
'name_en' => 'nullable|string|max:255',
'name_ja' => 'nullable|string|max:255',
'barcode' => 'nullable|string|max:100',
'spec' => 'nullable|string|max:255',
'manufacturer' => 'nullable|string|max:255',
'price' => 'required|numeric|min:0',
'member_price' => 'nullable|numeric|min:0',
'cost' => 'required|numeric|min:0',
'track_limit' => 'nullable|integer|min:0',
'spring_limit' => 'nullable|integer|min:0',
'material_code' => 'nullable|string|max:255',
'points_full' => 'nullable|integer|min:0',
'points_half' => 'nullable|integer|min:0',
'points_half_amount' => 'nullable|integer|min:0',
'is_active' => 'nullable|boolean',
]);
if ($validator->fails()) {
$this->appendError($results, $rowCount, $validator->errors()->all());
continue;
}
try {
DB::transaction(function () use ($data, $rowCount, $userId, &$results) {
$categoryId = $this->resolveCategoryId($data['category'], $data['company_id']);
$lookup = $this->buildProductLookup($data);
$product = Product::withoutGlobalScopes()->where($lookup)->first();
$oldValues = $product?->toArray();
$dictKey = $product?->name_dictionary_key ?: Str::uuid()->toString();
$this->syncTranslations($dictKey, $data, $data['company_id']);
$payload = [
'company_id' => $data['company_id'],
'category_id' => $categoryId,
'name' => $data['name_zh_TW'],
'name_dictionary_key' => $dictKey,
'barcode' => $data['barcode'],
'spec' => $data['spec'],
'manufacturer' => $data['manufacturer'],
'track_limit' => $data['track_limit'],
'spring_limit' => $data['spring_limit'],
'price' => $data['price'],
'cost' => $data['cost'],
'member_price' => $data['member_price'],
'metadata' => [
'material_code' => $data['material_code'],
'points_full' => $data['points_full'],
'points_half' => $data['points_half'],
'points_half_amount' => $data['points_half_amount'],
],
'is_active' => $data['is_active'],
];
if ($product) {
$product->update($payload);
$results['updated_count']++;
$action = 'update';
$note = 'product_import_updated';
} else {
$product = Product::withoutGlobalScopes()->create($payload);
$results['created_count']++;
$action = 'create';
$note = 'product_import_created';
}
$results['success_count']++;
if ($product->company_id) {
$results['company_ids'][$product->company_id] = $product->company_id;
}
SystemOperationLog::create([
'company_id' => $product->company_id,
'user_id' => $userId,
'module' => 'product',
'action' => $action,
'target_id' => $product->id,
'target_type' => Product::class,
'note' => $note,
'old_values' => $oldValues,
'new_values' => array_merge($product->fresh()->toArray(), ['import_row' => $rowCount]),
]);
});
} catch (\Exception $e) {
$this->appendError($results, $rowCount, [$e->getMessage()]);
}
}
}
$reader->close();
$results['company_ids'] = array_values($results['company_ids']);
return $results;
}
public function downloadTemplate()
{
$writer = new Writer();
$writer->openToBrowser('products_template.xlsx');
$writer->addRow(Row::fromValues([
'分類 ID 或名稱(選填)',
'商品名稱 zh_TW必填',
'商品名稱 en選填',
'商品名稱 ja選填',
'商品條碼(選填,用於更新)',
'規格(選填)',
'生產公司(選填)',
'售價(必填)',
'會員價(選填)',
'成本(必填)',
'履帶貨道上限(選填,預設 15',
'彈簧貨道上限(選填,預設 15',
'物料代碼(選填)',
'全額點數(選填)',
'半額點數(選填)',
'半額點數折抵金額(選填)',
'是否啟用1/0選填',
]));
$writer->addRow(Row::fromValues([
'飲料',
'經典拿鐵',
'Classic Latte',
'クラシックラテ',
'471000000001',
'350ml',
'Star Cloud',
80,
75,
45,
15,
15,
'MAT-LATTE-001',
800,
400,
40,
1,
]));
$writer->close();
}
private function normalizeRow(array $cells, ?int $companyId): array
{
return [
'company_id' => $companyId,
'category' => $this->stringValue($cells[0] ?? null),
'name_zh_TW' => $this->stringValue($cells[1] ?? null),
'name_en' => $this->stringValue($cells[2] ?? null),
'name_ja' => $this->stringValue($cells[3] ?? null),
'barcode' => $this->stringValue($cells[4] ?? null),
'spec' => $this->stringValue($cells[5] ?? null),
'manufacturer' => $this->stringValue($cells[6] ?? null),
'price' => $this->numericValue($cells[7] ?? null, null),
'member_price' => $this->numericValue($cells[8] ?? null, 0),
'cost' => $this->numericValue($cells[9] ?? null, null),
'track_limit' => (int) $this->numericValue($cells[10] ?? null, 15),
'spring_limit' => (int) $this->numericValue($cells[11] ?? null, 15),
'material_code' => $this->stringValue($cells[12] ?? null),
'points_full' => (int) $this->numericValue($cells[13] ?? null, 0),
'points_half' => (int) $this->numericValue($cells[14] ?? null, 0),
'points_half_amount' => (int) $this->numericValue($cells[15] ?? null, 0),
'is_active' => $this->booleanValue($cells[16] ?? null),
];
}
private function isEmptyRow(array $data): bool
{
return empty($data['category'])
&& empty($data['name_zh_TW'])
&& empty($data['barcode'])
&& $data['price'] === null
&& $data['cost'] === null;
}
private function resolveCategoryId(?string $category, ?int $companyId): ?int
{
if (!$category) {
return null;
}
$query = ProductCategory::withoutGlobalScopes()
->where(function ($q) use ($companyId) {
$q->where('company_id', $companyId)->orWhereNull('company_id');
});
if (ctype_digit($category)) {
$found = (clone $query)->where('id', (int) $category)->first();
if ($found) {
return $found->id;
}
}
$found = (clone $query)
->where(function ($q) use ($category) {
$q->where('name', $category)
->orWhereHas('translations', function ($translationQuery) use ($category) {
$translationQuery->withoutGlobalScopes()->where('value', $category);
});
})
->first();
if (!$found) {
throw new \InvalidArgumentException(__('Category not found: :category', ['category' => $category]));
}
return $found->id;
}
private function buildProductLookup(array $data): array
{
if (!empty($data['barcode'])) {
return [
'company_id' => $data['company_id'],
'barcode' => $data['barcode'],
];
}
return [
'company_id' => $data['company_id'],
'name' => $data['name_zh_TW'],
];
}
private function syncTranslations(string $dictKey, array $data, ?int $companyId): void
{
$names = [
'zh_TW' => $data['name_zh_TW'],
'en' => $data['name_en'],
'ja' => $data['name_ja'],
];
foreach ($names as $locale => $name) {
if (empty($name)) {
Translation::withoutGlobalScopes()->where([
'group' => 'product',
'key' => $dictKey,
'locale' => $locale,
])->delete();
continue;
}
Translation::withoutGlobalScopes()->updateOrCreate(
[
'group' => 'product',
'key' => $dictKey,
'locale' => $locale,
],
[
'value' => $name,
'company_id' => $companyId,
]
);
}
}
private function appendError(array &$results, int $rowCount, array $messages): void
{
$results['error_count']++;
$results['errors'][] = [
'row' => $rowCount,
'messages' => $messages,
];
}
private function stringValue(mixed $value): ?string
{
$value = trim((string) $value);
return $value === '' ? null : $value;
}
private function numericValue(mixed $value, mixed $default): mixed
{
if ($value === null || $value === '') {
return $default;
}
return is_numeric($value) ? $value : $value;
}
private function booleanValue(mixed $value): bool
{
if ($value === null || $value === '') {
return true;
}
if (is_bool($value)) {
return $value;
}
$normalized = strtolower(trim((string) $value));
return in_array($normalized, ['1', 'true', 'yes', 'y', 'active', 'enabled', '啟用', '是'], true);
}
}

View File

@ -6,6 +6,7 @@
"60 Seconds": "60 Seconds",
"60s": "60s",
":count staff cards imported successfully": ":count staff cards imported successfully",
":count products imported successfully": ":count products imported successfully",
"A new verification link has been sent to your email address.": "A new verification link has been sent to your email address.",
"A sync command was recently sent. Please wait 1 minute.": "A sync command was recently sent. Please wait 1 minute.",
"AI Prediction": "AI Prediction",
@ -256,6 +257,7 @@
"Cash Module Function": "Cash Module Function",
"Category": "Category",
"Category Management": "Category Management",
"Category not found: :category": "Category not found: :category",
"Category Name": "Category Name",
"Category Name (en)": "Category Name (en)",
"Category Name (ja)": "Category Name (ja)",
@ -809,8 +811,10 @@
"Image Downloaded": "Image Downloaded",
"Immediate": "Immediate",
"Import Excel": "Import Excel",
"Import Products": "Import Products",
"Import Staff Cards": "Import Staff Cards",
"Import failed": "Import failed",
"Imported with errors. Please check the console.": "Imported with errors. Please check the console.",
"Important Notice": "Important Notice",
"In Stock": "In Stock",
"Inactive": "Inactive",
@ -1225,6 +1229,7 @@
"Online Machines": "Online Machines",
"Online Only": "Online Only",
"Online Status": "Online Status",
"Only .xlsx files are supported (Max 5MB)": "Only .xlsx files are supported (Max 5MB)",
"Only .xlsx, .xls, .csv files are supported (Max 5MB)": "Only .xlsx, .xls, .csv files are supported (Max 5MB)",
"Only machines under the same company can be cloned for security.": "Only machines under the same company can be cloned for security.",
"Only system roles can be assigned to platform administrative accounts.": "Only system roles can be assigned to platform administrative accounts.",
@ -1745,6 +1750,7 @@
"Standby Ad": "Standby Ad",
"Start Date": "Start Date",
"Start Delivery": "Start Delivery",
"Processing...": "Processing...",
"Start Import": "Start Import",
"Start Time": "Start Time",
"Statistics": "Statistics",

View File

@ -6,6 +6,7 @@
"60 Seconds": "60 秒",
"60s": "60秒",
":count staff cards imported successfully": "成功匯入 :count 張員工識別卡",
":count products imported successfully": "成功匯入 :count 筆商品",
"A new verification link has been sent to your email address.": "已將新的驗證連結發送至您的電子郵件地址。",
"A sync command was recently sent. Please wait 1 minute.": "近期已發送過同步指令,請等待 1 分鐘後再試",
"AI Prediction": "AI智能預測",
@ -256,6 +257,7 @@
"Cash Module Function": "現金模組功能",
"Category": "類別",
"Category Management": "分類管理",
"Category not found: :category": "找不到商品分類::category",
"Category Name": "分類名稱",
"Category Name (en)": "分類名稱 (英文)",
"Category Name (ja)": "分類名稱 (日文)",
@ -809,8 +811,10 @@
"Image Downloaded": "圖片已下載",
"Immediate": "立即",
"Import Excel": "Excel 匯入",
"Import Products": "匯入商品",
"Import Staff Cards": "匯入員工識別卡",
"Import failed": "匯入失敗",
"Imported with errors. Please check the console.": "部分資料匯入失敗,請查看瀏覽器主控台。",
"Important Notice": "重要通知",
"In Stock": "當前庫存",
"Inactive": "已停用",
@ -1225,6 +1229,7 @@
"Online Machines": "在線機台",
"Online Only": "僅限線上機台",
"Online Status": "在線狀態",
"Only .xlsx files are supported (Max 5MB)": "僅支援 .xlsx 檔案 (最大 5MB)",
"Only .xlsx, .xls, .csv files are supported (Max 5MB)": "僅支援 .xlsx, .xls, .csv 檔案 (最大 5MB)",
"Only machines under the same company can be cloned for security.": "基於安全性,僅限複製同公司旗下的機台設定。",
"Only system roles can be assigned to platform administrative accounts.": "僅系統角色可指派給平台管理帳號。",
@ -1745,6 +1750,7 @@
"Standby Ad": "待機廣告",
"Start Date": "起始日",
"Start Delivery": "開始配送",
"Processing...": "處理中...",
"Start Import": "開始匯入",
"Start Time": "開始時間",
"Statistics": "數據統計",

View File

@ -23,23 +23,34 @@ hover:bg-slate-100 dark:hover:bg-white/5 rounded-lg flex items-center justify-be
{{-- Page Header --}}
<x-page-header :title="__('Product Management')"
:subtitle="__('Manage your catalog, prices, and multilingual details.')">
:subtitle="__('Manage your catalog, prices, and multilingual details.')"
container-class="flex flex-wrap md:flex-nowrap items-center justify-between gap-4"
actions-class="contents md:flex md:items-center md:gap-3 md:shrink-0">
<template x-if="activeTab === 'products'">
<div class="flex items-center gap-2 sm:gap-3">
<button @click="syncToAllMachines()"
class="btn-luxury-ghost group whitespace-nowrap flex items-center gap-2 transition-all duration-300 py-2 sm:py-2.5 px-4 sm:px-6 rounded-2xl border-slate-200 dark:border-white/10 hover:border-cyan-500/50 hover:bg-cyan-500/5">
<svg class="w-4 h-4 text-cyan-500 group-hover:rotate-180 transition-transform duration-700" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99" />
</svg>
<span class="text-sm font-black tracking-tight uppercase">{{ __('Sync to All Machines') }}</span>
</button>
<div class="contents md:flex md:items-center md:justify-end md:gap-3">
<a href="{{ route($baseRoute . '.create') }}"
class="btn-luxury-primary whitespace-nowrap flex items-center gap-2 transition-all duration-300 shadow-lg shadow-emerald-500/10">
class="btn-luxury-primary whitespace-nowrap flex items-center justify-center gap-2 transition-all duration-300 shadow-lg shadow-emerald-500/10 shrink-0 order-1 md:order-3">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
</svg>
<span class="text-sm sm:text-base font-black tracking-tight uppercase">{{ __('Add Product') }}</span>
</a>
<div class="order-2 w-full flex items-center gap-2 md:contents">
<button @click="isImportModalOpen = true" type="button"
class="btn-luxury-secondary whitespace-nowrap flex items-center gap-2 transition-all duration-300 flex-1 md:flex-none justify-center min-w-[126px] md:order-1">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" />
</svg>
<span class="text-sm sm:text-base font-black tracking-tight uppercase">{{ __('Import Excel') }}</span>
</button>
<button @click="syncToAllMachines()"
class="btn-luxury-ghost group whitespace-nowrap flex items-center justify-center gap-2 transition-all duration-300 py-2 sm:py-2.5 px-4 sm:px-6 rounded-2xl border-slate-200 dark:border-white/10 hover:border-cyan-500/50 hover:bg-cyan-500/5 flex-1 md:flex-none min-w-[150px] md:order-2">
<svg class="w-4 h-4 text-cyan-500 group-hover:rotate-180 transition-transform duration-700" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99" />
</svg>
<span class="text-sm font-black tracking-tight uppercase">{{ __('Sync to All Machines') }}</span>
</button>
</div>
</div>
</template>
<template x-if="activeTab === 'categories'">
@ -118,6 +129,109 @@ hover:bg-slate-100 dark:hover:bg-white/5 rounded-lg flex items-center justify-be
</div>
</div>
{{-- Import Products Modal --}}
<div x-show="isImportModalOpen" class="fixed inset-0 z-[110] overflow-y-auto" x-cloak>
<div class="flex items-center justify-center min-h-screen px-4 pt-4 pb-20 text-center sm:block sm:p-0">
<div x-show="isImportModalOpen" x-transition:enter="ease-out duration-300"
x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100"
class="fixed inset-0 transition-opacity bg-slate-900/60 backdrop-blur-sm"
@click="isImportModalOpen = false"></div>
<span class="hidden sm:inline-block sm:align-middle sm:h-screen">&#8203;</span>
<div x-show="isImportModalOpen" x-transition:enter="ease-out duration-300"
x-transition:enter-start="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
x-transition:enter-end="opacity-100 translate-y-0 sm:scale-100"
class="inline-block w-full max-w-lg p-8 my-8 overflow-hidden text-left align-middle transition-all transform bg-white dark:bg-slate-900 shadow-2xl rounded-3xl border border-slate-100 dark:border-white/10">
<div class="flex justify-between items-center mb-8">
<h3 class="text-2xl font-black text-slate-800 dark:text-white font-display tracking-tight">
{{ __('Import Products') }}
</h3>
<button @click="isImportModalOpen = false"
class="text-slate-400 hover:text-slate-600 dark:hover:text-slate-200 transition-colors">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<form @submit.prevent="submitImportForm" class="space-y-6" enctype="multipart/form-data">
<div class="space-y-5 px-1">
@if(auth()->user()->isSystemAdmin())
<div class="space-y-2">
<label class="flex items-center gap-2 text-[10px] font-black text-slate-400 uppercase tracking-[0.2em]">
{{ __('Affiliated Company') }} <span class="text-rose-500">*</span>
</label>
<x-searchable-select name="import_company_id" id="product-import-company-id" :options="$companies"
:placeholder="__('Select Company')"
@change="importFormFields.company_id = $event.target.value" required />
</div>
@endif
<div class="space-y-2">
<label class="flex items-center gap-2 text-[10px] font-black text-slate-400 uppercase tracking-[0.2em]">
{{ __('Excel File') }} <span class="text-rose-500">*</span>
</label>
<div class="relative group">
<input type="file" id="product-import-file" @change="importFormFields.file = $event.target.files[0]"
accept=".xlsx" class="luxury-input w-full pr-12 cursor-pointer" required>
<div class="absolute right-4 top-1/2 -translate-y-1/2 text-slate-400">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
</svg>
</div>
</div>
<p class="text-xs text-slate-400 mt-2 flex items-center gap-1">
<svg class="w-3.5 h-3.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
{{ __('Only .xlsx files are supported (Max 5MB)') }}
</p>
</div>
<div class="bg-slate-50 dark:bg-white/5 rounded-2xl p-4 border border-slate-100 dark:border-white/5">
<h4 class="text-xs font-black text-slate-500 dark:text-slate-400 uppercase tracking-wider mb-2">
{{ __('Download Template') }}
</h4>
<p class="text-xs text-slate-400 mb-4 leading-relaxed">
{{ __('Please use our standard template to ensure data compatibility.') }}
</p>
<a href="{{ route($baseRoute . '.template') }}" download
@click="setTimeout(() => { const bar = document.getElementById('top-loading-bar'); if(bar) bar.classList.remove('loading'); }, 1000);"
class="inline-flex items-center gap-2 text-sm font-bold text-cyan-600 hover:text-cyan-700 transition-colors group">
<span>{{ __('Download Template') }}</span>
<svg class="w-4 h-4 transition-transform group-hover:translate-y-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
</a>
</div>
</div>
<div class="flex items-center justify-end gap-4 mt-12 pt-6 border-t border-slate-100 dark:border-white/5">
<button type="button" @click="isImportModalOpen = false"
class="px-6 py-2.5 text-sm font-black text-slate-400 hover:text-slate-600 dark:hover:text-slate-200 uppercase tracking-widest transition-all">
{{ __('Cancel') }}
</button>
<button type="submit" class="btn-luxury-primary px-10 py-3 shadow-lg bg-cyan-600 hover:bg-cyan-700 shadow-cyan-500/20"
:disabled="loading">
<template x-if="!loading">
<span>{{ __('Start Import') }}</span>
</template>
<template x-if="loading">
<div class="flex items-center gap-2">
<svg class="animate-spin h-4 w-4 text-white" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<span>{{ __('Processing...') }}</span>
</div>
</template>
</button>
</div>
</form>
</div>
</div>
</div>
<!-- Category Modal -->
<div x-show="isCategoryModalOpen" class="fixed inset-0 z-[110] overflow-y-auto" x-cloak>
<div class="flex items-center justify-center min-h-screen px-4 pt-4 pb-20 text-center sm:block sm:p-0">
@ -319,7 +433,7 @@ hover:bg-slate-100 dark:hover:bg-white/5 rounded-lg flex items-center justify-be
<div class="text-[15px] font-bold text-slate-700 dark:text-slate-200"
x-text="selectedProduct?.spec || '-'"></div>
</div>
<template x-if="selectedProduct?.metadata?.material_code">
<template x-if="isProductFeatureEnabled('enable_material_code') && selectedProduct?.metadata?.material_code">
<div
class="bg-slate-50 dark:bg-slate-800/40 p-5 rounded-2xl border border-slate-100 dark:border-slate-800/80 group hover:border-cyan-500/30 transition-colors">
<span
@ -395,7 +509,7 @@ hover:bg-slate-100 dark:hover:bg-white/5 rounded-lg flex items-center justify-be
</section>
<!-- Loyalty Points -->
<section class="space-y-4 animate-luxury-in" style="animation-delay: 400ms">
<section x-show="isProductFeatureEnabled('enable_points')" class="space-y-4 animate-luxury-in" style="animation-delay: 400ms">
<h3 class="text-xs font-black text-rose-500 uppercase tracking-[0.3em]">{{ __('Marketing and Loyalty') }}</h3>
<div class="grid grid-cols-1 sm:grid-cols-3 gap-3">
<div
@ -687,6 +801,7 @@ hover:bg-slate-100 dark:hover:bg-white/5 rounded-lg flex items-center justify-be
isImageZoomed: false,
isStatusConfirmOpen: false,
isCategoryModalOpen: false,
isImportModalOpen: false,
confirmModal: {
show: false,
type: 'sync_products',
@ -706,14 +821,17 @@ hover:bg-slate-100 dark:hover:bg-white/5 rounded-lg flex items-center justify-be
expandedLogs: [],
categories: [],
companies: [],
companySettings: {},
translations: {},
categoryFormFields: {
names: { zh_TW: '', en: '', ja: '' },
company_id: ''
},
importFormFields: { company_id: '{{ auth()->user()->company_id ?? "" }}', file: null },
init() {
this.categories = JSON.parse(this.$el.dataset.categories || '[]');
this.companySettings = JSON.parse(this.$el.dataset.settings || '{}');
this.companies = @js($companies);
this.translations = @js([
'Sync Products' => __('Sync Products'),
@ -729,6 +847,8 @@ hover:bg-slate-100 dark:hover:bg-white/5 rounded-lg flex items-center justify-be
'Log Details' => __('Log Details'),
]);
this.showPendingImportToast();
// Initial binding
this.bindPaginationLinks('#tab-products-container', 'products');
this.bindPaginationLinks('#tab-categories-container', 'categories');
@ -790,6 +910,24 @@ hover:bg-slate-100 dark:hover:bg-white/5 rounded-lg flex items-center justify-be
});
},
showPendingImportToast() {
const payload = sessionStorage.getItem('productImportToast');
if (!payload) return;
sessionStorage.removeItem('productImportToast');
try {
const toast = JSON.parse(payload);
setTimeout(() => {
if (window.showToast) {
window.showToast(toast.message, toast.type || 'success');
}
}, 300);
} catch (error) {
console.error('Failed to parse import toast payload:', error);
}
},
toggleLog(id) {
const idx = this.expandedLogs.indexOf(id);
if (idx > -1) {
@ -961,6 +1099,20 @@ hover:bg-slate-100 dark:hover:bg-white/5 rounded-lg flex items-center justify-be
window.HSStaticMethods.autoInit();
}
if (container._ajaxNavigateHandler) {
container.removeEventListener('ajax:navigate', container._ajaxNavigateHandler);
}
container._ajaxNavigateHandler = (e) => {
e.preventDefault();
const url = e.detail?.url;
if (url) {
this.fetchTabData(tab, url);
}
};
container.addEventListener('ajax:navigate', container._ajaxNavigateHandler);
// 1. Intercept Standard Links
container.querySelectorAll('nav a, .pagination a').forEach(link => {
// Prevent multiple bindings
@ -1061,6 +1213,14 @@ hover:bg-slate-100 dark:hover:bg-white/5 rounded-lg flex items-center justify-be
this.isDetailOpen = true;
},
getSelectedProductSettings() {
return this.selectedProduct?.company?.settings || this.companySettings || {};
},
isProductFeatureEnabled(key) {
return Boolean(this.getSelectedProductSettings()?.[key]);
},
openCategoryModal(category = null) {
if (category) {
this.categoryModalMode = 'edit';
@ -1093,6 +1253,53 @@ hover:bg-slate-100 dark:hover:bg-white/5 rounded-lg flex items-center justify-be
this.isStatusConfirmOpen = true;
},
async submitImportForm() {
if (!this.importFormFields.file) return;
this.loading = true;
const formData = new FormData();
formData.append('file', this.importFormFields.file);
if (this.importFormFields.company_id) {
formData.append('company_id', this.importFormFields.company_id);
}
try {
const response = await fetch(`{{ route($baseRoute . '.import') }}`, {
method: 'POST',
headers: {
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').getAttribute('content'),
'Accept': 'application/json',
'X-Requested-With': 'XMLHttpRequest'
},
body: formData
});
const data = await response.json();
if (data.success) {
this.isImportModalOpen = false;
this.importFormFields.file = null;
const input = document.getElementById('product-import-file');
if (input) input.value = '';
sessionStorage.setItem('productImportToast', JSON.stringify({
message: data.message,
type: 'success'
}));
if (data.results.error_count > 0) {
console.error('Product import partially failed:', data.results.errors);
}
window.location.reload();
} else {
if (window.showToast) window.showToast(data.message || 'Error', 'error');
}
} catch (error) {
console.error('Product import error:', error);
if (window.showToast) window.showToast('{{ __("Import failed") }}', 'error');
} finally {
this.loading = false;
}
},
submitConfirmedForm() {
if (this.isStatusConfirmOpen) {
this.isStatusConfirmOpen = false;
@ -1222,4 +1429,4 @@ hover:bg-slate-100 dark:hover:bg-white/5 rounded-lg flex items-center justify-be
}));
});
</script>
@endsection
@endsection

View File

@ -363,6 +363,6 @@ $baseRoute = 'admin.data-config.products';
<!-- Pagination -->
<div class="mt-6 py-6 border-t border-slate-50 dark:border-slate-800/50">
{{ $products->links('vendor.pagination.luxury') }}
{{ $products->links('vendor.pagination.luxury', ['page_param' => 'product_page']) }}
</div>
</div>
</div>

View File

@ -40,9 +40,11 @@
@props([
'title' => '',
'subtitle' => '',
'containerClass' => 'flex items-center justify-between gap-4',
'actionsClass' => 'flex items-center gap-3 shrink-0',
])
<div class="flex items-center justify-between gap-4">
<div class="{{ $containerClass }}">
{{-- 左側:標題 + 副標題 --}}
<div class="flex items-center gap-4 min-w-0">
@if(isset($back))
@ -64,7 +66,7 @@
{{-- 右側Action 按鈕(透過 slot 傳入,無傳入則不渲染) --}}
@if($slot->isNotEmpty())
<div class="flex items-center gap-3 shrink-0">
<div class="{{ $actionsClass }}">
{{ $slot }}
</div>
@endif

View File

@ -168,6 +168,8 @@ Route::middleware(['auth', 'auth.session', 'verified', 'tenant.access'])->prefix
// 9. 資料設定
Route::prefix('data-config')->name('data-config.')->group(function () {
Route::middleware('can:menu.data-config.products')->group(function () {
Route::get('/products/template', [App\Http\Controllers\Admin\ProductController::class, 'downloadTemplate'])->name('products.template');
Route::post('/products/import', [App\Http\Controllers\Admin\ProductController::class, 'import'])->name('products.import');
Route::resource('products', App\Http\Controllers\Admin\ProductController::class)->except(['show']);
Route::patch('/products/{id}/toggle-status', [App\Http\Controllers\Admin\ProductController::class, 'toggleStatus'])->name('products.status.toggle');
Route::post('/products/sync-all', [App\Http\Controllers\Admin\ProductController::class, 'syncToAllMachines'])->name('products.sync-all');