[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:
parent
b0fc0e6520
commit
c3e60b8746
@ -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();
|
||||
|
||||
@ -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)
|
||||
*/
|
||||
|
||||
348
app/Services/ProductImportService.php
Normal file
348
app/Services/ProductImportService.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@ -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",
|
||||
|
||||
@ -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": "數據統計",
|
||||
|
||||
@ -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">​</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
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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');
|
||||
|
||||
Loading…
Reference in New Issue
Block a user