[STYLE] 商品管理介面多語系標準化與側邊欄 UI 優化
1. 完成商品詳細資訊側邊欄 (Slide-over) 的全面多語系化,修復部分標題顯示英文的問題。 2. 重構 Alpine.js 的 getCategoryName 函式,優先顯示 localized_name。 3. 更新 zh_TW.json 補齊缺失的翻譯鍵值 (包含同步指令提示、側邊欄區塊標題等)。 4. 優化分頁與分頁大小切換邏輯,支援新增的日誌 (Logs) 分頁。 5. 調整同步指令錯誤訊息為通用語氣,提升不同角色下的使用者體驗。
This commit is contained in:
parent
87d3369792
commit
256d996918
@ -8,6 +8,7 @@ use App\Models\System\Translation;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
use App\Models\System\SystemOperationLog;
|
||||
|
||||
class ProductCategoryController extends Controller
|
||||
{
|
||||
@ -73,6 +74,17 @@ class ProductCategoryController extends Controller
|
||||
'name_dictionary_key' => $dictKey,
|
||||
]);
|
||||
|
||||
SystemOperationLog::create([
|
||||
'company_id' => $category->company_id,
|
||||
'user_id' => auth()->id(),
|
||||
'module' => 'category',
|
||||
'action' => 'create',
|
||||
'target_id' => $category->id,
|
||||
'target_type' => ProductCategory::class,
|
||||
'note' => 'category_created',
|
||||
'new_values' => $category->toArray(),
|
||||
]);
|
||||
|
||||
DB::commit();
|
||||
|
||||
return redirect()->back()->with('success', __('Category created successfully'));
|
||||
@ -125,10 +137,24 @@ class ProductCategoryController extends Controller
|
||||
);
|
||||
}
|
||||
|
||||
$oldValues = $category->toArray();
|
||||
$category->update([
|
||||
'name' => $request->names['zh_TW'] ?? $category->name,
|
||||
'name_dictionary_key' => $dictKey,
|
||||
]);
|
||||
$newValues = $category->fresh()->toArray();
|
||||
|
||||
SystemOperationLog::create([
|
||||
'company_id' => $category->company_id,
|
||||
'user_id' => auth()->id(),
|
||||
'module' => 'category',
|
||||
'action' => 'update',
|
||||
'target_id' => $category->id,
|
||||
'target_type' => ProductCategory::class,
|
||||
'note' => 'category_updated',
|
||||
'old_values' => $oldValues,
|
||||
'new_values' => $newValues,
|
||||
]);
|
||||
|
||||
DB::commit();
|
||||
|
||||
@ -157,12 +183,22 @@ class ProductCategoryController extends Controller
|
||||
return redirect()->back()->with('error', $errorMsg);
|
||||
}
|
||||
|
||||
if ($category->name_dictionary_key) {
|
||||
Translation::withoutGlobalScopes()->where('group', 'category')->where('key', $category->name_dictionary_key)->delete();
|
||||
}
|
||||
|
||||
$oldValues = $category->toArray();
|
||||
$categoryId = $category->id;
|
||||
$companyId = $category->company_id;
|
||||
$category->delete();
|
||||
|
||||
SystemOperationLog::create([
|
||||
'company_id' => $companyId,
|
||||
'user_id' => auth()->id(),
|
||||
'module' => 'category',
|
||||
'action' => 'delete',
|
||||
'target_id' => $categoryId,
|
||||
'target_type' => ProductCategory::class,
|
||||
'note' => 'category_deleted',
|
||||
'old_values' => $oldValues,
|
||||
]);
|
||||
|
||||
if (request()->ajax()) {
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
|
||||
@ -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\Models\System\SystemOperationLog;
|
||||
|
||||
class ProductController extends Controller
|
||||
{
|
||||
@ -91,7 +92,7 @@ class ProductController extends Controller
|
||||
'routeName' => $routeName
|
||||
])->render()
|
||||
]);
|
||||
} else {
|
||||
} elseif ($tab === 'categories') {
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'html' => view('admin.products.partials.tab-categories', [
|
||||
@ -100,6 +101,38 @@ class ProductController extends Controller
|
||||
'routeName' => $routeName
|
||||
])->render()
|
||||
]);
|
||||
} elseif ($tab === 'logs') {
|
||||
$logQuery = SystemOperationLog::with(['user', 'company'])
|
||||
->whereIn('module', ['product', 'category']);
|
||||
|
||||
if ($user->isSystemAdmin()) {
|
||||
if ($request->filled('log_company_id')) {
|
||||
$logQuery->where('company_id', $request->log_company_id);
|
||||
}
|
||||
}
|
||||
|
||||
if ($request->filled('search_log')) {
|
||||
$search = $request->search_log;
|
||||
$logQuery->where(function($q) use ($search) {
|
||||
$q->where('note', 'like', "%{$search}%")
|
||||
->orWhere('old_values', 'like', "%{$search}%")
|
||||
->orWhere('new_values', 'like', "%{$search}%");
|
||||
});
|
||||
}
|
||||
|
||||
if ($request->filled('start_date') && $request->filled('end_date')) {
|
||||
$logQuery->whereBetween('created_at', [$request->start_date, $request->end_date]);
|
||||
}
|
||||
|
||||
$logs = $logQuery->latest()->paginate($per_page, ['*'], 'log_page')->withQueryString();
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'html' => view('admin.products.partials.tab-logs', [
|
||||
'logs' => $logs,
|
||||
'companies' => $companies
|
||||
])->render()
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@ -223,6 +256,17 @@ class ProductController extends Controller
|
||||
'is_active' => $request->boolean('is_active', true),
|
||||
]);
|
||||
|
||||
SystemOperationLog::create([
|
||||
'company_id' => $product->company_id,
|
||||
'user_id' => auth()->id(),
|
||||
'module' => 'product',
|
||||
'action' => 'create',
|
||||
'target_id' => $product->id,
|
||||
'target_type' => Product::class,
|
||||
'note' => 'product_created',
|
||||
'new_values' => $product->toArray(),
|
||||
]);
|
||||
|
||||
DB::commit();
|
||||
|
||||
// Rebuild catalog cache
|
||||
@ -336,7 +380,21 @@ class ProductController extends Controller
|
||||
$data['image_url'] = null;
|
||||
}
|
||||
|
||||
$oldValues = $product->toArray();
|
||||
$product->update($data);
|
||||
$newValues = $product->fresh()->toArray();
|
||||
|
||||
SystemOperationLog::create([
|
||||
'company_id' => $product->company_id,
|
||||
'user_id' => auth()->id(),
|
||||
'module' => 'product',
|
||||
'action' => 'update',
|
||||
'target_id' => $product->id,
|
||||
'target_type' => Product::class,
|
||||
'note' => 'product_updated',
|
||||
'old_values' => $oldValues,
|
||||
'new_values' => $newValues,
|
||||
]);
|
||||
|
||||
DB::commit();
|
||||
|
||||
@ -366,8 +424,22 @@ class ProductController extends Controller
|
||||
{
|
||||
try {
|
||||
$product = Product::findOrFail($id);
|
||||
$oldValues = $product->toArray();
|
||||
$product->is_active = !$product->is_active;
|
||||
$product->save();
|
||||
$newValues = $product->fresh()->toArray();
|
||||
|
||||
SystemOperationLog::create([
|
||||
'company_id' => $product->company_id,
|
||||
'user_id' => auth()->id(),
|
||||
'module' => 'product',
|
||||
'action' => 'update',
|
||||
'target_id' => $product->id,
|
||||
'target_type' => Product::class,
|
||||
'note' => 'product_status_toggled',
|
||||
'old_values' => $oldValues,
|
||||
'new_values' => $newValues,
|
||||
]);
|
||||
|
||||
// Rebuild catalog cache
|
||||
$this->catalogService->rebuildCache($product->company_id);
|
||||
@ -411,8 +483,20 @@ class ProductController extends Controller
|
||||
}
|
||||
|
||||
$companyId = $product->company_id;
|
||||
$oldValues = $product->toArray();
|
||||
$product->delete();
|
||||
|
||||
SystemOperationLog::create([
|
||||
'company_id' => $companyId,
|
||||
'user_id' => auth()->id(),
|
||||
'module' => 'product',
|
||||
'action' => 'delete',
|
||||
'target_id' => $id,
|
||||
'target_type' => Product::class,
|
||||
'note' => 'product_deleted',
|
||||
'old_values' => $oldValues,
|
||||
]);
|
||||
|
||||
// Rebuild catalog cache
|
||||
$this->catalogService->rebuildCache($companyId);
|
||||
|
||||
@ -478,6 +562,17 @@ class ProductController extends Controller
|
||||
$user->id
|
||||
);
|
||||
|
||||
SystemOperationLog::create([
|
||||
'company_id' => $companyId,
|
||||
'user_id' => $user->id,
|
||||
'module' => 'product',
|
||||
'action' => 'sync',
|
||||
'target_id' => $companyId,
|
||||
'target_type' => Company::class,
|
||||
'note' => 'product_catalog_synced_to_all_machines',
|
||||
'new_values' => ['remark' => $remark],
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => __('Batch sync command has been queued. Machines will be updated sequentially.')
|
||||
|
||||
@ -24,10 +24,13 @@ class SystemOperationLog extends Model
|
||||
return null;
|
||||
}
|
||||
|
||||
return __($this->note, array_merge(
|
||||
// 過濾掉非字串/數值的數值,避免 Laravel 翻譯引擎處理巢狀陣列時報錯
|
||||
$replacements = collect(array_merge(
|
||||
$this->old_values ?? [],
|
||||
$this->new_values ?? []
|
||||
));
|
||||
))->filter(fn($val) => is_scalar($val))->toArray();
|
||||
|
||||
return __($this->note, $replacements);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -240,6 +240,7 @@
|
||||
"Change Stock": "零錢庫存",
|
||||
"Channel Limits": "貨道上限",
|
||||
"Channel Limits (Track/Spring)": "貨道上限 (履帶/彈簧)",
|
||||
"Channel Limits Config": "貨道上限配置",
|
||||
"Channel Limits Configuration": "貨道上限配置",
|
||||
"ChannelId": "ChannelId",
|
||||
"ChannelSecret": "ChannelSecret",
|
||||
@ -688,6 +689,7 @@
|
||||
"ITEM": "ITEM",
|
||||
"Identification": "識別資訊",
|
||||
"Identity & Codes": "識別與代碼",
|
||||
"Identity and Codes": "識別與代碼",
|
||||
"Image": "圖片",
|
||||
"Image Downloaded": "圖片已下載",
|
||||
"Immediate": "立即",
|
||||
@ -782,6 +784,7 @@
|
||||
"Low Stock Alerts": "低庫存警示",
|
||||
"Low stock and out-of-stock alerts": "低庫存與缺貨警示",
|
||||
"Loyalty & Features": "行銷與點數",
|
||||
"Marketing and Loyalty": "行銷與點數",
|
||||
"Machine": "機台",
|
||||
"Machine / Date": "機台 / 日期",
|
||||
"Machine / Employee": "機台 / 員工",
|
||||
@ -1963,5 +1966,27 @@
|
||||
"Manual Sync Products": "手動同步商品",
|
||||
"Please select a company.": "請選擇公司",
|
||||
"Batch sync command has been queued. Machines will be updated sequentially.": "批次同步指令已進入隊列,機台將依序進行更新",
|
||||
"A sync command was recently sent. Please wait 1 minute.": "近期已發送過同步指令,請等待 1 分鐘後再試"
|
||||
"A sync command was recently sent. Please wait 1 minute.": "近期已發送過同步指令,請等待 1 分鐘後再試",
|
||||
"Operation Logs": "操作紀錄",
|
||||
"product": "商品",
|
||||
"category": "分類",
|
||||
"product_created": "商品已建立",
|
||||
"product_updated": "商品資訊已更新",
|
||||
"product_status_toggled": "商品狀態已切換",
|
||||
"product_deleted": "商品已刪除",
|
||||
"product_catalog_synced_to_all_machines": "商品目錄已同步至所有機台",
|
||||
"category_created": "分類已建立",
|
||||
"category_updated": "分類資訊已更新",
|
||||
"category_deleted": "分類已刪除",
|
||||
"Search notes or values...": "搜尋備註或數值...",
|
||||
"Filter by Company": "依公司篩選",
|
||||
"No operation logs found": "找不到操作紀錄",
|
||||
"Old Value": "舊資料",
|
||||
"New Value": "新資料",
|
||||
"Data Changes": "資料異動詳情",
|
||||
"Field": "欄位",
|
||||
"Old": "舊值",
|
||||
"New": "新值",
|
||||
"No changes detected": "未偵測到資料變更",
|
||||
"Log Details": "操作紀錄詳情"
|
||||
}
|
||||
@ -57,6 +57,7 @@ hover:bg-slate-100 dark:hover:bg-white/5 rounded-lg flex items-center justify-be
|
||||
<x-tab-nav>
|
||||
<x-tab-nav-item value="products" :label="__('Product List')" />
|
||||
<x-tab-nav-item value="categories" :label="__('Category Management')" />
|
||||
<x-tab-nav-item value="logs" :label="__('Operation Logs')" />
|
||||
</x-tab-nav>
|
||||
|
||||
{{-- Tab Contents --}}
|
||||
@ -98,6 +99,25 @@ hover:bg-slate-100 dark:hover:bg-white/5 rounded-lg flex items-center justify-be
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Logs Tab --}}
|
||||
<div x-show="activeTab === 'logs'"
|
||||
class="luxury-card rounded-3xl p-6 animate-luxury-in relative min-h-[300px]"
|
||||
x-transition:enter="transition ease-out duration-300" x-transition:enter-start="opacity-0 translate-y-4"
|
||||
x-transition:enter-end="opacity-100 translate-y-0" x-cloak>
|
||||
|
||||
{{-- Loading Overlay (Logs) --}}
|
||||
<x-luxury-spinner show="tabLoading === 'logs'" z-index="z-20">
|
||||
<svg class="w-6 h-6 text-cyan-500 animate-pulse" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</x-luxury-spinner>
|
||||
|
||||
<div id="tab-logs-container" class="relative">
|
||||
{{-- Content will be loaded via AJAX --}}
|
||||
</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">
|
||||
@ -284,7 +304,7 @@ hover:bg-slate-100 dark:hover:bg-white/5 rounded-lg flex items-center justify-be
|
||||
</template>
|
||||
|
||||
<section class="space-y-4 animate-luxury-in" style="animation-delay: 100ms">
|
||||
<h3 class="text-xs font-black text-cyan-500 uppercase tracking-[0.3em]">{{ __('Identity & Codes') }}</h3>
|
||||
<h3 class="text-xs font-black text-cyan-500 uppercase tracking-[0.3em]">{{ __('Identity and Codes') }}</h3>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<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">
|
||||
@ -356,7 +376,7 @@ hover:bg-slate-100 dark:hover:bg-white/5 rounded-lg flex items-center justify-be
|
||||
|
||||
<!-- Storage & Limits -->
|
||||
<section class="space-y-4 animate-luxury-in" style="animation-delay: 300ms">
|
||||
<h3 class="text-xs font-black text-indigo-500 uppercase tracking-[0.3em]">{{ __('Channel Limits Configuration') }}</h3>
|
||||
<h3 class="text-xs font-black text-indigo-500 uppercase tracking-[0.3em]">{{ __('Channel Limits Config') }}</h3>
|
||||
<div
|
||||
class="luxury-card p-6 border border-slate-100 dark:border-white/5 space-y-6 shadow-sm">
|
||||
<div class="flex items-center justify-between">
|
||||
@ -377,7 +397,7 @@ hover:bg-slate-100 dark:hover:bg-white/5 rounded-lg flex items-center justify-be
|
||||
|
||||
<!-- Loyalty Points -->
|
||||
<section class="space-y-4 animate-luxury-in" style="animation-delay: 400ms">
|
||||
<h3 class="text-xs font-black text-rose-500 uppercase tracking-[0.3em]">{{ __('Loyalty & Features') }}</h3>
|
||||
<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
|
||||
class="bg-slate-50 dark:bg-slate-800/40 p-5 rounded-2xl border border-slate-100 dark:border-slate-800/80">
|
||||
@ -451,6 +471,120 @@ hover:bg-slate-100 dark:hover:bg-white/5 rounded-lg flex items-center justify-be
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Log Detail Side Panel -->
|
||||
<div
|
||||
class="fixed inset-0 z-[110] overflow-hidden"
|
||||
x-show="isLogPanelOpen"
|
||||
x-cloak
|
||||
@keydown.window.escape="isLogPanelOpen = false"
|
||||
>
|
||||
<!-- Backdrop -->
|
||||
<div class="absolute inset-0 bg-slate-900/60 backdrop-blur-sm"
|
||||
x-show="isLogPanelOpen"
|
||||
x-transition:enter="ease-out duration-500"
|
||||
x-transition:enter-start="opacity-0"
|
||||
x-transition:enter-end="opacity-100"
|
||||
x-transition:leave="ease-in duration-500"
|
||||
x-transition:leave-start="opacity-100"
|
||||
x-transition:leave-end="opacity-0"
|
||||
@click="isLogPanelOpen = false"
|
||||
></div>
|
||||
|
||||
<!-- Panel Wrapper -->
|
||||
<div class="pointer-events-none fixed inset-y-0 right-0 flex max-w-full pl-10">
|
||||
<div
|
||||
class="pointer-events-auto w-screen max-w-md"
|
||||
x-show="isLogPanelOpen"
|
||||
x-transition:enter="transform transition ease-in-out duration-500 sm:duration-700"
|
||||
x-transition:enter-start="translate-x-full"
|
||||
x-transition:enter-end="translate-x-0"
|
||||
x-transition:leave="transform transition ease-in-out duration-500 sm:duration-700"
|
||||
x-transition:leave-start="translate-x-0"
|
||||
x-transition:leave-end="translate-x-full"
|
||||
>
|
||||
<div class="flex h-full flex-col overflow-y-auto bg-white dark:bg-slate-900 shadow-2xl border-l border-slate-100 dark:border-slate-800">
|
||||
|
||||
<!-- Header -->
|
||||
<div class="px-6 py-4 border-b border-slate-100 dark:border-slate-800 flex items-center justify-between sticky top-0 bg-white/80 dark:bg-slate-900/80 backdrop-blur-md z-20">
|
||||
<div>
|
||||
<h2 class="text-xl font-black text-slate-800 dark:text-white">{{ __('Log Details') }}</h2>
|
||||
<p class="text-[11px] font-bold text-slate-400 uppercase tracking-[0.2em] mt-1"
|
||||
x-text="(selectedLog?.module || '') + ' / ' + (selectedLog?.action || '')">
|
||||
</p>
|
||||
</div>
|
||||
<button @click="isLogPanelOpen = false"
|
||||
class="p-2 rounded-xl hover:bg-slate-50 dark:hover:bg-slate-800 transition-colors">
|
||||
<svg class="w-6 h-6 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Body -->
|
||||
<div class="flex-1 p-6 space-y-6">
|
||||
|
||||
<!-- Meta Info -->
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="p-4 rounded-2xl bg-slate-50 dark:bg-slate-800/40 border border-slate-100 dark:border-slate-800/80">
|
||||
<span class="text-[10px] font-black text-slate-400 uppercase tracking-widest block mb-1.5">{{ __('Time') }}</span>
|
||||
<div class="text-sm font-black text-slate-700 dark:text-slate-200" x-text="selectedLog?.created_at"></div>
|
||||
</div>
|
||||
<div class="p-4 rounded-2xl bg-slate-50 dark:bg-slate-800/40 border border-slate-100 dark:border-slate-800/80">
|
||||
<span class="text-[10px] font-black text-slate-400 uppercase tracking-widest block mb-1.5">{{ __('Operator') }}</span>
|
||||
<div class="text-sm font-black text-slate-700 dark:text-slate-200" x-text="selectedLog?.user?.name || 'System'"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Note -->
|
||||
<div class="p-5 rounded-2xl bg-slate-50 dark:bg-slate-800/40 border border-slate-100 dark:border-slate-800/80">
|
||||
<span class="text-[10px] font-black text-slate-400 uppercase tracking-widest block mb-1.5">{{ __('Target') }}</span>
|
||||
<div class="text-sm font-black text-slate-800 dark:text-slate-100" x-text="selectedLog?.translated_note"></div>
|
||||
</div>
|
||||
|
||||
<!-- Data Changes -->
|
||||
<div class="space-y-4">
|
||||
<h3 class="text-xs font-black text-cyan-500 uppercase tracking-[0.3em]">{{ __('Data Changes') }}</h3>
|
||||
|
||||
<div class="space-y-3">
|
||||
<template x-for="change in getLogChanges(selectedLog)" :key="change.key">
|
||||
<div class="p-5 rounded-2xl border border-slate-100 dark:border-white/5 bg-white dark:bg-slate-800/30">
|
||||
<div class="text-xs font-black text-slate-400 uppercase tracking-widest mb-3" x-text="change.label"></div>
|
||||
<div class="space-y-2">
|
||||
<div>
|
||||
<span class="text-[10px] font-bold text-rose-400 uppercase tracking-tighter">{{ __('Old Value') }}</span>
|
||||
<div class="mt-1 text-xs font-bold text-rose-500 bg-rose-500/5 p-3 rounded-xl border border-rose-500/10 line-through decoration-rose-500/30" x-text="change.old"></div>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-[10px] font-bold text-emerald-400 uppercase tracking-tighter">{{ __('New Value') }}</span>
|
||||
<div class="mt-1 text-xs font-bold text-emerald-500 bg-emerald-500/5 p-3 rounded-xl border border-emerald-500/10" x-text="change.new"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template x-if="getLogChanges(selectedLog).length === 0">
|
||||
<div class="p-12 text-center">
|
||||
<div class="w-16 h-16 bg-slate-50 dark:bg-slate-800/50 rounded-2xl flex items-center justify-center mx-auto mb-4 border border-slate-100 dark:border-slate-800">
|
||||
<svg class="w-8 h-8 text-slate-300" 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>
|
||||
</div>
|
||||
<p class="text-sm font-bold text-slate-400">{{ __('No changes detected') }}</p>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="p-6 border-t border-slate-100 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-900/50">
|
||||
<button @click="isLogPanelOpen = false" class="w-full btn-luxury-ghost">{{ __('Close') }}</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Custom Confirmation Modal (Mirroring Remote Control) -->
|
||||
@ -553,7 +687,6 @@ hover:bg-slate-100 dark:hover:bg-white/5 rounded-lg flex items-center justify-be
|
||||
isDetailOpen: false,
|
||||
isImageZoomed: false,
|
||||
isStatusConfirmOpen: false,
|
||||
isDeleteConfirmOpen: false,
|
||||
isCategoryModalOpen: false,
|
||||
confirmModal: {
|
||||
show: false,
|
||||
@ -569,8 +702,12 @@ hover:bg-slate-100 dark:hover:bg-white/5 rounded-lg flex items-center justify-be
|
||||
deleteFormAction: '',
|
||||
toggleFormAction: '',
|
||||
selectedProduct: null,
|
||||
selectedLog: null,
|
||||
isLogPanelOpen: false,
|
||||
expandedLogs: [],
|
||||
categories: [],
|
||||
companies: [],
|
||||
translations: {},
|
||||
categoryFormFields: {
|
||||
names: { zh_TW: '', en: '', ja: '' },
|
||||
company_id: ''
|
||||
@ -585,11 +722,22 @@ hover:bg-slate-100 dark:hover:bg-white/5 rounded-lg flex items-center justify-be
|
||||
'Failed to send command' => __('Failed to send command'),
|
||||
'Select Target Company' => __('Select Target Company'),
|
||||
'Search Company...' => __('Search Company...'),
|
||||
'Field' => __('Field'),
|
||||
'Old' => __('Old'),
|
||||
'New' => __('New'),
|
||||
'No changes detected' => __('No changes detected'),
|
||||
'Close' => __('Close'),
|
||||
'Log Details' => __('Log Details'),
|
||||
]);
|
||||
|
||||
// Initial binding
|
||||
this.bindPaginationLinks('#tab-products-container', 'products');
|
||||
this.bindPaginationLinks('#tab-categories-container', 'categories');
|
||||
this.bindPaginationLinks('#tab-logs-container', 'logs');
|
||||
|
||||
if (this.activeTab === 'logs') {
|
||||
this.fetchTabData('logs');
|
||||
}
|
||||
|
||||
this.$watch('confirmModal.show', (value) => {
|
||||
if (value && document.getElementById('sync_company_select_wrapper')) {
|
||||
@ -627,15 +775,91 @@ hover:bg-slate-100 dark:hover:bg-white/5 rounded-lg flex items-center justify-be
|
||||
else bar.classList.remove('loading');
|
||||
}
|
||||
});
|
||||
|
||||
// Sync tab to URL (Clean up parameters from other tabs when switching)
|
||||
this.$watch('activeTab', (val) => {
|
||||
const url = new URL(window.location.origin + window.location.pathname);
|
||||
url.searchParams.set('tab', val);
|
||||
window.history.pushState({}, '', url);
|
||||
|
||||
// Auto fetch logs if empty
|
||||
if (val === 'logs') {
|
||||
const container = document.getElementById('tab-logs-container');
|
||||
if (container && container.innerHTML.trim() === '') {
|
||||
this.fetchTabData('logs');
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
toggleLog(id) {
|
||||
const idx = this.expandedLogs.indexOf(id);
|
||||
if (idx > -1) {
|
||||
this.expandedLogs.splice(idx, 1);
|
||||
} else {
|
||||
this.expandedLogs.push(id);
|
||||
}
|
||||
},
|
||||
|
||||
viewLogDetails(log) {
|
||||
console.log('Viewing log details:', log);
|
||||
this.selectedLog = log;
|
||||
this.isLogPanelOpen = true;
|
||||
},
|
||||
|
||||
getLogChanges(log) {
|
||||
if (!log) return [];
|
||||
const oldVals = log.old_values || {};
|
||||
const newVals = log.new_values || {};
|
||||
const changes = [];
|
||||
const keys = new Set([...Object.keys(oldVals), ...Object.keys(newVals)]);
|
||||
|
||||
// 排除不需顯示的欄位
|
||||
const excludeKeys = ['id', 'created_at', 'updated_at', 'deleted_at', 'name_dictionary_key', 'company_id', 'user_id', 'image_url'];
|
||||
|
||||
keys.forEach(key => {
|
||||
if (excludeKeys.includes(key)) return;
|
||||
|
||||
const ov = oldVals[key];
|
||||
const nv = newVals[key];
|
||||
|
||||
if (JSON.stringify(ov) !== JSON.stringify(nv)) {
|
||||
changes.push({
|
||||
key: key,
|
||||
label: this.getFieldLabel(key),
|
||||
old: this.formatValue(ov),
|
||||
new: this.formatValue(nv)
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return changes;
|
||||
},
|
||||
|
||||
getFieldLabel(key) {
|
||||
const labels = {
|
||||
'name': '{{ __("Name") }}',
|
||||
'price': '{{ __("Price") }}',
|
||||
'cost': '{{ __("Cost") }}',
|
||||
'member_price': '{{ __("Member Price") }}',
|
||||
'barcode': '{{ __("Barcode") }}',
|
||||
'spec': '{{ __("Spec") }}',
|
||||
'manufacturer': '{{ __("Manufacturer") }}',
|
||||
'track_limit': '{{ __("Track Limit") }}',
|
||||
'spring_limit': '{{ __("Spring Limit") }}',
|
||||
'is_active': '{{ __("Is Active") }}',
|
||||
'category_id': '{{ __("Category") }}',
|
||||
'metadata': '{{ __("Metadata") }}'
|
||||
};
|
||||
return labels[key] || key;
|
||||
},
|
||||
|
||||
formatValue(val) {
|
||||
if (val === null || val === undefined) return '-';
|
||||
if (typeof val === 'boolean') return val ? '{{ __("Yes") }}' : '{{ __("No") }}';
|
||||
if (typeof val === 'object') return JSON.stringify(val);
|
||||
return val;
|
||||
},
|
||||
|
||||
async fetchTabData(tab, url = null) {
|
||||
this.tabLoading = tab;
|
||||
|
||||
@ -722,6 +946,7 @@ hover:bg-slate-100 dark:hover:bg-white/5 rounded-lg flex items-center justify-be
|
||||
// Explicitly remove page parameters to reset to page 1
|
||||
params.delete('product_page');
|
||||
params.delete('category_page');
|
||||
params.delete('log_page');
|
||||
|
||||
const url = window.location.pathname + '?' + params.toString();
|
||||
this.fetchTabData(tab, url);
|
||||
@ -764,7 +989,7 @@ hover:bg-slate-100 dark:hover:bg-white/5 rounded-lg flex items-center justify-be
|
||||
// Per page limit change: needs to build URL
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
params.set('per_page', sel.value);
|
||||
const pageKey = tab === 'products' ? 'product_page' : 'category_page';
|
||||
const pageKey = tab === 'products' ? 'product_page' : (tab === 'categories' ? 'category_page' : 'log_page');
|
||||
params.delete(pageKey); // Reset to page 1
|
||||
newUrl = window.location.pathname + '?' + params.toString();
|
||||
}
|
||||
|
||||
189
resources/views/admin/products/partials/tab-logs.blade.php
Normal file
189
resources/views/admin/products/partials/tab-logs.blade.php
Normal file
@ -0,0 +1,189 @@
|
||||
{{-- Filter Form --}}
|
||||
<form action="{{ route('admin.data-config.products.index') }}" method="GET"
|
||||
class="flex flex-wrap items-center gap-3 sm:gap-4 mb-8" @submit.prevent="handleFilterSubmit('logs')">
|
||||
<input type="hidden" name="tab" value="logs">
|
||||
|
||||
{{-- Company Filter (If Admin) --}}
|
||||
@if(auth()->user()->isSystemAdmin())
|
||||
<div class="w-full sm:w-64">
|
||||
<select name="log_company_id" class="hidden"
|
||||
data-hs-select='{
|
||||
"placeholder": "{{ __("Filter by Company") }}",
|
||||
"toggleClasses": "hs-select-toggle luxury-select-toggle",
|
||||
"dropdownClasses": "hs-select-menu w-full bg-white dark:bg-slate-900 border border-slate-200 dark:border-white/10 rounded-xl shadow-2xl mt-2 z-[100]",
|
||||
"optionClasses": "hs-select-option py-2 px-3 text-sm text-slate-800 dark:text-slate-300 cursor-pointer hover:bg-slate-100 dark:hover:bg-white/5 rounded-lg flex items-center justify-between",
|
||||
"hasSearch": true,
|
||||
"searchPlaceholder": "{{ __("Search Company...") }}"
|
||||
}'>
|
||||
<option value="">{{ __('All Companies') }}</option>
|
||||
@foreach($companies as $company)
|
||||
<option value="{{ $company->id }}" {{ request('log_company_id') == $company->id ? 'selected' : '' }}>
|
||||
{{ $company->name }}
|
||||
</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Search Keyword --}}
|
||||
<div class="relative group w-full sm:w-64 sm:flex-none">
|
||||
<span class="absolute inset-y-0 left-0 flex items-center pl-4 pointer-events-none z-10 text-slate-400 group-focus-within:text-cyan-500 transition-colors">
|
||||
<svg class="w-4 h-4 stroke-[2.5]" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="11" cy="11" r="8"></circle>
|
||||
<line x1="21" x2="16.65" y2="16.65"></line>
|
||||
</svg>
|
||||
</span>
|
||||
<input type="text" name="search_log" value="{{ request('search_log') }}"
|
||||
class="py-2.5 pl-12 pr-6 block w-full luxury-input text-sm font-bold"
|
||||
placeholder="{{ __('Search notes or values...') }}">
|
||||
</div>
|
||||
|
||||
{{-- Date Range --}}
|
||||
<div class="relative group w-full sm:w-72 sm:flex-none"
|
||||
x-data="{ fp: null }"
|
||||
x-init="fp = flatpickr($refs.dateRange, {
|
||||
mode: 'range',
|
||||
dateFormat: 'Y-m-d H:i',
|
||||
enableTime: true,
|
||||
time_24hr: true,
|
||||
locale: '{{ app()->getLocale() == 'zh_TW' ? 'zh_tw' : 'en' }}',
|
||||
defaultDate: '{{ request('start_date') }}' && '{{ request('end_date') }}' ? ['{{ request('start_date') }}', '{{ request('end_date') }}'] : [],
|
||||
onChange: function(selectedDates, dateStr, instance) {
|
||||
if (selectedDates.length === 2) {
|
||||
$refs.startDate.value = instance.formatDate(selectedDates[0], 'Y-m-d H:i');
|
||||
$refs.endDate.value = instance.formatDate(selectedDates[1], 'Y-m-d H:i');
|
||||
} else if (selectedDates.length === 0) {
|
||||
$refs.startDate.value = '';
|
||||
$refs.endDate.value = '';
|
||||
}
|
||||
}
|
||||
})">
|
||||
<span class="absolute inset-y-0 left-0 flex items-center pl-4 pointer-events-none z-10 text-slate-400 group-focus-within:text-cyan-500 transition-colors">
|
||||
<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" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" /></svg>
|
||||
</span>
|
||||
<input type="hidden" name="start_date" x-ref="startDate" value="{{ request('start_date') }}">
|
||||
<input type="hidden" name="end_date" x-ref="endDate" value="{{ request('end_date') }}">
|
||||
<input type="text" x-ref="dateRange"
|
||||
value="{{ request('start_date') && request('end_date') ? request('start_date') . ' 至 ' . request('end_date') : (request('start_date') ?: '') }}"
|
||||
placeholder="{{ __('Select Date Range') }}" class="luxury-input py-2.5 pl-12 pr-6 block w-full text-sm font-bold cursor-pointer">
|
||||
</div>
|
||||
|
||||
{{-- Buttons --}}
|
||||
<div class="flex items-center gap-2 ml-auto sm:ml-0">
|
||||
<button type="submit"
|
||||
class="p-2.5 rounded-xl bg-cyan-500 text-white hover:bg-cyan-600 shadow-lg shadow-cyan-500/25 transition-all active:scale-95">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button type="button" @click="
|
||||
$el.closest('form').querySelectorAll('input[type=text], input[type=hidden]:not([name=tab]), select').forEach(i => i.value = '');
|
||||
fetchTabData('logs', '{{ route('admin.data-config.products.index') }}?tab=logs');
|
||||
"
|
||||
class="p-2.5 rounded-xl bg-slate-100 dark:bg-slate-800 text-slate-500 hover:bg-slate-200 dark:hover:bg-slate-700 transition-all active:scale-95">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{{-- Desktop Table --}}
|
||||
<div class="hidden xl:block overflow-x-auto">
|
||||
<table class="w-full text-left border-separate border-spacing-y-0">
|
||||
<thead>
|
||||
<tr class="bg-slate-50/50 dark:bg-slate-900/10">
|
||||
<th class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-widest border-b border-slate-100 dark:border-slate-800">
|
||||
{{ __('Time') }}
|
||||
</th>
|
||||
<th class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-widest border-b border-slate-100 dark:border-slate-800 text-center">
|
||||
{{ __('Action') }}
|
||||
</th>
|
||||
<th class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-widest border-b border-slate-100 dark:border-slate-800">
|
||||
{{ __('Module') }} / {{ __('Target') }}
|
||||
</th>
|
||||
<th class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-widest border-b border-slate-100 dark:border-slate-800">
|
||||
{{ __('Note') }}
|
||||
</th>
|
||||
<th class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-widest border-b border-slate-100 dark:border-slate-800 text-right">
|
||||
{{ __('Details') }}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-50 dark:divide-slate-800/80">
|
||||
@forelse($logs as $log)
|
||||
<tr class="group hover:bg-slate-50/80 dark:hover:bg-slate-800/40 transition-all duration-300">
|
||||
<td class="px-6 py-5 whitespace-nowrap">
|
||||
<span class="text-xs font-mono font-black text-slate-700 dark:text-slate-200 tracking-tighter">
|
||||
{{ $log->created_at?->format('Y-m-d H:i:s') }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-5 text-center">
|
||||
<x-status-badge :status="$log->action" size="sm" />
|
||||
</td>
|
||||
<td class="px-6 py-5">
|
||||
<div class="flex flex-col">
|
||||
<span class="text-[10px] font-black text-indigo-500 uppercase tracking-widest">{{ __($log->module) }}</span>
|
||||
<span class="text-sm font-black text-slate-800 dark:text-slate-100">#{{ $log->target_id }}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-5">
|
||||
<p class="text-sm font-bold text-slate-600 dark:text-slate-400">{{ $log->translated_note }}</p>
|
||||
</td>
|
||||
<td class="px-6 py-5 text-right whitespace-nowrap">
|
||||
<button type="button" @click="viewLogDetails({{ $log }})"
|
||||
class="p-2 rounded-lg bg-slate-100 dark:bg-slate-800 text-slate-500 hover:text-cyan-500 hover:bg-cyan-50 dark:hover:bg-cyan-500/10 transition-all active:scale-95">
|
||||
<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" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
||||
</svg>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr>
|
||||
<td colspan="5" class="px-6 py-20 text-center">
|
||||
<x-empty-state :message="__('No operation logs found')" />
|
||||
</td>
|
||||
</tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{{-- Card Mode (Mobile) --}}
|
||||
<div class="xl:hidden space-y-4">
|
||||
@forelse($logs as $log)
|
||||
<div class="luxury-card p-5 rounded-[2rem] border border-slate-100 dark:border-slate-800 bg-white/50 dark:bg-slate-900/50">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="px-2.5 py-1 rounded-lg bg-indigo-500/10 text-indigo-500 text-[10px] font-black uppercase tracking-widest">
|
||||
{{ __($log->module) }}
|
||||
</span>
|
||||
<x-status-badge :status="$log->action" size="xs" />
|
||||
</div>
|
||||
<span class="text-[10px] font-mono font-bold text-slate-400">{{ $log->created_at?->format('H:i:s') }}</span>
|
||||
</div>
|
||||
|
||||
<p class="text-sm font-black text-slate-800 dark:text-slate-100 mb-2">{{ $log->translated_note }}</p>
|
||||
|
||||
<div class="flex items-center justify-between pt-3 border-t border-slate-100 dark:border-white/5">
|
||||
<div class="flex flex-col">
|
||||
<span class="text-xs font-bold text-slate-400">#{{ $log->target_id }}</span>
|
||||
<span class="text-[10px] font-black text-slate-500 uppercase">{{ $log->user->name ?? 'System' }}</span>
|
||||
</div>
|
||||
<button type="button" @click="viewLogDetails({{ $log }})"
|
||||
class="px-4 py-2 rounded-xl bg-slate-100 dark:bg-slate-800 text-slate-600 dark:text-slate-400 text-xs font-black hover:bg-cyan-500 hover:text-white transition-all">
|
||||
{{ __('Details') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@empty
|
||||
<x-empty-state :message="__('No operation logs found')" />
|
||||
@endforelse
|
||||
</div>
|
||||
|
||||
{{-- Pagination --}}
|
||||
<div class="mt-8 py-6 border-t border-slate-50 dark:border-slate-800/50">
|
||||
{{ $logs->links('vendor.pagination.luxury') }}
|
||||
</div>
|
||||
Loading…
Reference in New Issue
Block a user