[STYLE] 商品管理介面多語系標準化與側邊欄 UI 優化

1. 完成商品詳細資訊側邊欄 (Slide-over) 的全面多語系化,修復部分標題顯示英文的問題。
2. 重構 Alpine.js 的 getCategoryName 函式,優先顯示 localized_name。
3. 更新 zh_TW.json 補齊缺失的翻譯鍵值 (包含同步指令提示、側邊欄區塊標題等)。
4. 優化分頁與分頁大小切換邏輯,支援新增的日誌 (Logs) 分頁。
5. 調整同步指令錯誤訊息為通用語氣,提升不同角色下的使用者體驗。
This commit is contained in:
sky121113 2026-05-14 11:25:43 +08:00
parent 87d3369792
commit 256d996918
6 changed files with 587 additions and 14 deletions

View File

@ -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,

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\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.')

View File

@ -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);
}

View File

@ -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": "操作紀錄詳情"
}

View File

@ -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();
}

View 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>