[FIX] 徹底修復商品管理分頁參數洩漏與 UI 狀態不一致問題
All checks were successful
star-cloud-deploy-demo / deploy-demo (push) Successful in 59s

1. 隔離商品列表與分類管理的搜尋參數(product_search / category_search)。
2. 隔離商品列表與分類管理的公司篩選參數(product_company_id / category_company_id)。
3. 優化分頁切換邏輯,切換 Tab 時自動清理 URL 參數,解決標籤殘留問題。
4. 修復 searchable-select 組件因屬性傳遞錯誤導致的 500 Internal Server Error。
5. 統一各分頁「公司篩選」Placeholder 為「所有公司」。
6. 完成分類管理搜尋框的多語系支援(新增 Search categories... 翻譯鍵值)。
7. 優化分頁器 (Pagination) 樣式以符合極簡奢華風規範。
This commit is contained in:
2026-04-15 16:45:13 +08:00
parent 24553d9b73
commit 1301bf1cb8
10 changed files with 670 additions and 247 deletions

View File

@@ -150,7 +150,11 @@ class ProductCategoryController extends Controller
// 檢查是否已有商品使用此分類
if ($category->products()->count() > 0) {
return redirect()->back()->with('error', __('Cannot delete category that has products. Please move products first.'));
$errorMsg = __('Cannot delete category that has products. Please move products first.');
if (request()->ajax()) {
return response()->json(['success' => false, 'message' => $errorMsg], 422);
}
return redirect()->back()->with('error', $errorMsg);
}
if ($category->name_dictionary_key) {
@@ -159,8 +163,18 @@ class ProductCategoryController extends Controller
$category->delete();
if (request()->ajax()) {
return response()->json([
'success' => true,
'message' => __('Category deleted successfully')
]);
}
return redirect()->back()->with('success', __('Category deleted successfully'));
} catch (\Exception $e) {
if (request()->ajax()) {
return response()->json(['success' => false, 'message' => $e->getMessage()], 500);
}
return redirect()->back()->with('error', $e->getMessage());
}
}

View File

@@ -19,12 +19,16 @@ class ProductController extends Controller
public function index(Request $request)
{
$user = auth()->user();
$query = Product::with(['category.translations', 'translations', 'company']);
$tab = $request->input('tab', 'products');
$per_page = $request->input('per_page', 10);
// Products Query
$productQuery = Product::with(['category.translations', 'translations', 'company']);
// 搜尋
if ($request->filled('search')) {
$search = $request->search;
$query->where(function($q) use ($search) {
if ($request->filled('product_search')) {
$search = $request->product_search;
$productQuery->where(function($q) use ($search) {
$q->where('name', 'like', "%{$search}%")
->orWhere('barcode', 'like', "%{$search}%")
->orWhere('spec', 'like', "%{$search}%");
@@ -33,29 +37,66 @@ class ProductController extends Controller
// 分類篩選
if ($request->filled('category_id')) {
$query->where('category_id', $request->category_id);
$productQuery->where('category_id', $request->category_id);
}
$per_page = $request->input('per_page', 10);
$companyId = $user->company_id;
if ($user->isSystemAdmin()) {
if ($request->filled('company_id')) {
$companyId = $request->company_id;
$query->where('company_id', $companyId);
if ($request->filled('product_company_id')) {
$productQuery->where('company_id', $request->product_company_id);
}
}
$products = $query->latest()->paginate($per_page)->withQueryString();
$categories = ProductCategory::with('translations')->get();
$products = $productQuery->latest()->paginate($per_page, ['*'], 'product_page')->withQueryString();
// Categories Query
$categoryQuery = ProductCategory::with(['translations', 'company']);
if ($user->isSystemAdmin() && $request->filled('category_company_id')) {
$categoryQuery->where('company_id', $request->category_company_id);
}
if ($request->filled('category_search')) {
$search = $request->category_search;
$categoryQuery->where(function($q) use ($search) {
$q->where('name', 'like', "%{$search}%");
});
}
$categories = $categoryQuery->latest()->paginate($per_page, ['*'], 'category_page')->withQueryString();
$companies = $user->isSystemAdmin() ? Company::all() : collect();
// 系統管理員在過濾特定公司時,應顯示該公司的功能開關 (如物料代碼、點數規則)
$selectedCompany = $companyId ? Company::find($companyId) : $user->company;
// Settings for Modal (use current user company or fallback)
$selectedCompanyId = $user->isSystemAdmin()
? ($request->input('product_company_id') ?: $request->input('category_company_id'))
: $user->company_id;
$selectedCompany = $selectedCompanyId ? Company::find($selectedCompanyId) : $user->company;
$companySettings = $selectedCompany ? ($selectedCompany->settings ?? []) : [];
$routeName = 'admin.data-config.products.index';
if ($request->ajax() || $request->wantsJson()) {
if ($tab === 'products') {
return response()->json([
'success' => true,
'html' => view('admin.products.partials.tab-products', [
'products' => $products,
'companySettings' => $companySettings,
'companies' => $companies,
'routeName' => $routeName
])->render()
]);
} else {
return response()->json([
'success' => true,
'html' => view('admin.products.partials.tab-categories', [
'categories' => $categories,
'companies' => $companies,
'routeName' => $routeName
])->render()
]);
}
}
return view('admin.products.index', [
'products' => $products,
'categories' => $categories,
@@ -313,8 +354,23 @@ class ProductController extends Controller
$product->save();
$status = $product->is_active ? __('Enabled') : __('Disabled');
if (request()->ajax()) {
return response()->json([
'success' => true,
'message' => __('Product status updated to :status', ['status' => $status]),
'is_active' => $product->is_active
]);
}
return redirect()->back()->with('success', __('Product status updated to :status', ['status' => $status]));
} catch (\Exception $e) {
if (request()->ajax()) {
return response()->json([
'success' => false,
'message' => $e->getMessage()
], 500);
}
return redirect()->back()->with('error', $e->getMessage());
}
}
@@ -337,8 +393,21 @@ class ProductController extends Controller
$product->delete();
if (request()->ajax()) {
return response()->json([
'success' => true,
'message' => __('Product deleted successfully')
]);
}
return redirect()->back()->with('success', __('Product deleted successfully'));
} catch (\Exception $e) {
if (request()->ajax()) {
return response()->json([
'success' => false,
'message' => $e->getMessage()
], 500);
}
return redirect()->back()->with('error', $e->getMessage());
}
}

View File

@@ -85,6 +85,7 @@
"Apply changes to all identical products in this machine": "Apply changes to all identical products in this machine",
"Apply to all identical products in this machine": "Apply to all identical products in this machine",
"Are you sure to delete this customer?": "Are you sure to delete this customer?",
"Are you sure you want to change the status of this item? This will affect its visibility on vending machines.": "Are you sure you want to change the status of this item? This will affect its visibility on vending machines.",
"Are you sure you want to change the status of this product? Disabled products will not be visible on the machine.": "Are you sure you want to change the status of this product? Disabled products will not be visible on the machine.",
"Are you sure you want to change the status? After disabling, this account will no longer be able to log in to the system.": "Are you sure you want to change the status? After disabling, this account will no longer be able to log in to the system.",
"Are you sure you want to change the status? This may affect associated accounts.": "Are you sure you want to change the status? This may affect associated accounts.",
@@ -95,6 +96,7 @@
"Are you sure you want to delete this configuration?": "您確定要刪除此金流配置嗎?",
"Are you sure you want to delete this configuration? This action cannot be undone.": "Are you sure you want to delete this configuration? This action cannot be undone.",
"Are you sure you want to delete this item? This action cannot be undone.": "Are you sure you want to delete this item? This action cannot be undone.",
"Are you sure you want to delete this product or category? This action cannot be undone.": "Are you sure you want to delete this product or category? This action cannot be undone.",
"Are you sure you want to delete this product?": "Are you sure you want to delete this product?",
"Are you sure you want to delete this product? All related historical translation data will also be removed.": "Are you sure you want to delete this product? All related historical translation data will also be removed.",
"Are you sure you want to delete this role? This action cannot be undone.": "Are you sure you want to delete this role? This action cannot be undone.",
@@ -828,6 +830,7 @@
"Search by name or S\/N...": "Search by name or S\/N...",
"Search cargo lane": "Search cargo lane",
"Search Company Title...": "Search Company Title...",
"Search categories...": "Search categories...",
"Search company...": "Search company...",
"Search configurations...": "Search configurations...",
"Search customers...": "Search customers...",
@@ -1155,5 +1158,8 @@
"Expired Time": "Expired Time",
"Inventory synced with machine": "Inventory synced with machine",
"Failed to load tab content": "Failed to load tab content",
"No machines found": "No machines found"
"No machines found": "No machines found",
"No products found matching your criteria.": "No products found matching your criteria.",
"No categories found.": "No categories found.",
"Track Limit (Track/Spring)": "Track Limit (Track/Spring)"
}

View File

@@ -85,6 +85,7 @@
"Apply changes to all identical products in this machine": "この機台の同一商品すべてに変更を適用",
"Apply to all identical products in this machine": "この機体内のすべての同一商品に適用する",
"Are you sure to delete this customer?": "この顧客を削除してもよろしいですか?",
"Are you sure you want to change the status of this item? This will affect its visibility on vending machines.": "この項目のステータスを変更してもよろしいですか?自動販売機での表示に影響します。",
"Are you sure you want to change the status of this product? Disabled products will not be visible on the machine.": "この商品のステータスを変更してもよろしいですか?無効にすると機体に表示されなくなります。",
"Are you sure you want to change the status? After disabling, this account will no longer be able to log in to the system.": "ステータスを変更してもよろしいですか?無効にすると、このアカウントはシステムにログインできなくなります。",
"Are you sure you want to change the status? This may affect associated accounts.": "ステータスを変更してもよろしいですか?関連するアカウントに影響を与える可能性があります。",
@@ -95,6 +96,7 @@
"Are you sure you want to delete this configuration?": "この設定を削除してもよろしいですか?",
"Are you sure you want to delete this configuration? This action cannot be undone.": "この設定を削除してもよろしいですか?この操作は取り消せません。",
"Are you sure you want to delete this item? This action cannot be undone.": "この項目を削除してもよろしいですか?この操作は取り消せません。",
"Are you sure you want to delete this product or category? This action cannot be undone.": "この商品またはカテゴリーを削除してもよろしいですか?この操作は取り消せません。",
"Are you sure you want to delete this product?": "この商品を削除してもよろしいですか?",
"Are you sure you want to delete this product? All related historical translation data will also be removed.": "この商品を削除してもよろしいですか?関連するすべての翻訳履歴データも削除されます。",
"Are you sure you want to delete this role? This action cannot be undone.": "この権限を削除してもよろしいですか?この操作は取り消せません。",
@@ -827,6 +829,7 @@
"Search by name or S\/N...": "名称または製造番号で検索...",
"Search cargo lane": "貨道を検索",
"Search Company Title...": "会社名を検索...",
"Search categories...": "カテゴリーを検索...",
"Search company...": "会社を検索...",
"Search configurations...": "設定を検索...",
"Search customers...": "顧客を検索...",
@@ -1154,5 +1157,8 @@
"Expired Time": "終了時間",
"Inventory synced with machine": "在庫が機体と同期されました",
"Failed to load tab content": "タブコンテンツの読み込みに失敗しました",
"No machines found": "マシンが見つかりません"
"No machines found": "マシンが見つかりません",
"No products found matching your criteria.": "条件に一致する商品が見つかりませんでした。",
"No categories found.": "カテゴリーが見つかりませんでした。",
"Track Limit (Track/Spring)": "在庫上限 (ベルト/スプリング)"
}

View File

@@ -87,6 +87,7 @@
"Apply changes to all identical products in this machine": "同步套用至此機台內的所有相同商品",
"Apply to all identical products in this machine": "同步套用至此機台內的所有相同商品",
"Are you sure to delete this customer?": "您確定要刪除此客戶嗎?",
"Are you sure you want to change the status of this item? This will affect its visibility on vending machines.": "您確定要變更此項目的狀態嗎?這將會影響其在販賣機上的顯示內容。",
"Are you sure you want to change the status of this product? Disabled products will not be visible on the machine.": "確定要變更此商品的狀態嗎?停用的商品將不會在機台上顯示。",
"Are you sure you want to change the status? After disabling, this account will no longer be able to log in to the system.": "您確定要變更狀態嗎?停用之後,該帳號將會立即被登出且無法再登入系統。",
"Are you sure you want to change the status? This may affect associated accounts.": "您確定要變更狀態嗎?這可能會影響相關帳號的權限效力。",
@@ -97,6 +98,7 @@
"Are you sure you want to delete this configuration?": "您確定要刪除此金流配置嗎?",
"Are you sure you want to delete this configuration? This action cannot be undone.": "您確定要刪除此金流配置嗎?此操作將無法復原。",
"Are you sure you want to delete this item? This action cannot be undone.": "確定要刪除此項目嗎?此操作無法復原。",
"Are you sure you want to delete this product or category? This action cannot be undone.": "您確定要刪除此商品或分類嗎?此操作將無法復原。",
"Are you sure you want to delete this product?": "您確定要刪除此商品嗎?",
"Are you sure you want to delete this product? All related historical translation data will also be removed.": "確定要刪除此商品嗎?所有相關的歷史翻譯數據也將被移除。",
"Are you sure you want to delete this role? This action cannot be undone.": "您確定要刪除此角色嗎?此操作將無法復原。",
@@ -636,6 +638,7 @@
"OEE.Sales": "銷售",
"of": "/共",
"Offline": "離線",
"of items": "筆項目",
"Offline Machines": "離線機台",
"Once your account is deleted, all of its resources and data will be permanently deleted. Before deleting your account, please download any data or information that you wish to retain.": "一旦您的帳號被刪除,其所有資源和數據將被永久刪除。在刪除帳號之前,請下載您希望保留的任何數據或資訊。",
"Once your account is deleted, all of its resources and data will be permanently deleted. Please enter your password to confirm you would like to permanently delete your account.": "帳號一旦刪除,所有關連數據將被永久移除。請輸入您的密碼以確認您希望永久刪除此帳號。",
@@ -831,6 +834,7 @@
"Search by name or S\/N...": "搜尋名稱或序號...",
"Search cargo lane": "搜尋貨道編號或商品名稱",
"Search Company Title...": "搜尋公司名稱...",
"Search categories...": "搜尋分類...",
"Search company...": "搜尋公司...",
"Search configurations...": "搜尋設定...",
"Search customers...": "搜尋客戶...",
@@ -880,7 +884,7 @@
"Show": "顯示",
"Show material code field in products": "在商品資料中顯示物料編號欄位",
"Show points rules in products": "在商品資料中顯示點數規則相關欄位",
"Showing": "顯示",
"Showing": "目前顯示",
"Showing :from to :to of :total items": "顯示第 :from 到 :to 項,共 :total 項",
"Sign in to your account": "隨時隨地掌控您的業務。",
"Signed in as": "登入身份",
@@ -985,7 +989,10 @@
"Track Channel Limit": "履帶貨道上限",
"Track device health and maintenance history": "追蹤設備健康與維修歷史",
"Track Limit": "履帶貨道上限",
"Track Limit (Track/Spring)": "貨道上限(履帶/彈簧)",
"Traditional Chinese": "繁體中文",
"Track": "履帶",
"Spring": "彈簧",
"Transfer Audit": "調撥單",
"Transfers": "調撥單",
"Trigger": "觸發",
@@ -1160,5 +1167,7 @@
"Expired Time": "下架時間",
"Inventory synced with machine": "庫存已與機台同步",
"Failed to load tab content": "載入分頁內容失敗",
"No machines found": "未找到機台"
"No machines found": "未找到機台",
"No products found matching your criteria.": "找不到符合條件的商品。",
"No categories found.": "找不到分類。"
}

View File

@@ -17,7 +17,7 @@ $roleSelectConfig = [
@section('content')
<div class="space-y-2 pb-20"
x-data="productManager"
data-categories="{{ json_encode($categories) }}"
data-categories="{{ json_encode($categories->items()) }}"
data-settings="{{ json_encode($companySettings) }}"
data-errors="{{ json_encode($errors->any()) }}"
data-store-url="{{ route($baseRoute . '.store') }}"
@@ -69,222 +69,72 @@ $roleSelectConfig = [
<!-- Tab Contents -->
<div class="mt-6">
<!-- Products Tab -->
<div x-show="activeTab === 'products'" class="luxury-card rounded-3xl p-8 animate-luxury-in" 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>
<!-- Filters & Search -->
<form action="{{ route($routeName) }}" method="GET" class="flex flex-col md:flex-row md:items-center gap-4 mb-10">
<div class="relative group">
<span class="absolute inset-y-0 left-0 flex items-center pl-4 pointer-events-none z-10">
<svg class="h-4 w-4 text-slate-400 group-focus-within:text-cyan-500 transition-colors" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<circle cx="11" cy="11" r="8"></circle><line x1="21" y1="21" x2="16.65" y2="16.65"></line>
<div x-show="activeTab === 'products'"
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 (Products) -->
<div x-show="tabLoading === 'products'" x-transition:enter="transition ease-out duration-300"
x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100"
x-transition:leave="transition ease-in duration-200" x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
class="absolute inset-0 z-20 bg-white/40 dark:bg-slate-900/40 backdrop-blur-[1px] flex flex-col items-center justify-center rounded-3xl"
x-cloak>
<div class="relative w-16 h-16 mb-4 flex items-center justify-center">
<div class="absolute inset-0 rounded-full border-2 border-transparent border-t-cyan-500 border-r-cyan-500/30 animate-spin"></div>
<div class="absolute inset-2 rounded-full border border-cyan-500/10 animate-spin" style="animation-duration: 3s; direction: reverse;"></div>
<div class="relative w-8 h-8 flex items-center justify-center">
<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="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-14v10l-8-4m16 4l-8 4m0-10l-8 4" />
</svg>
</span>
<input type="text" name="search" value="{{ request('search') }}" class="py-2.5 pl-12 pr-6 block w-full md:w-80 luxury-input" placeholder="{{ __('Search products...') }}">
</div>
</div>
<p class="text-[10px] font-black text-cyan-600 dark:text-cyan-400 uppercase tracking-[0.4em] animate-pulse">
{{ __('Loading Data') }}...</p>
</div>
@if(auth()->user()->isSystemAdmin())
<div class="relative min-w-[200px]">
<x-searchable-select name="company_id" :options="$companies" :selected="request('company_id')" :placeholder="__('All Companies')" onchange="this.form.submit()" />
</div>
@endif
</form>
<!-- Table -->
<div class="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-[0.15em] border-b border-slate-100 dark:border-slate-800">{{ __('Product Info') }}</th>
<th class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800">{{ __('Barcode') }}</th>
@if(auth()->user()->isSystemAdmin())
<th class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800 text-center">{{ __('Company') }}</th>
@endif
<th class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800 text-center">{{ __('Sale Price') }}</th>
<th class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800 text-center">{{ __('Channel Limits (Track/Spring)') }}</th>
<th class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800 text-center">{{ __('Status') }}</th>
<th class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800 text-right">{{ __('Actions') }}</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-50 dark:divide-slate-800/80">
@forelse($products as $product)
<tr class="group hover:bg-slate-50/80 dark:hover:bg-slate-800/40 transition-all duration-300">
<td class="px-6 py-6 cursor-pointer group/info" @click="viewProductDetail(@js($product))" title="{{ __('View Details') }}">
<div class="flex items-center gap-x-4">
<div class="w-10 h-10 rounded-xl bg-slate-100 dark:bg-slate-800 flex items-center justify-center text-slate-400 border border-slate-200 dark:border-slate-700 group-hover/info:bg-cyan-500 group-hover/info:text-white group-hover/info:border-cyan-500 shadow-sm group-hover/info:shadow-cyan-500/50 transition-all duration-300 overflow-hidden">
@if($product->image_url)
<img src="{{ $product->image_url }}" class="w-full h-full object-cover">
@else
<svg class="size-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="m21 7.5-9-5.25L3 7.5m18 0-9 5.25m9-5.25v9l-9 5.25M3 7.5l9 5.25M3 7.5v9l9 5.25m0-9v9" /></svg>
@endif
</div>
<div class="flex flex-col">
<span class="text-base font-extrabold text-slate-800 dark:text-slate-100 group-hover/info:text-cyan-600 dark:group-hover/info:text-cyan-400 transition-colors">{{ $product->localized_name }}</span>
<div class="flex flex-wrap items-center gap-1.5 mt-1">
@php
$catName = $product->category->localized_name ?? __('Uncategorized');
@endphp
<span class="text-[10px] font-bold text-slate-500 dark:text-slate-400 uppercase tracking-widest bg-slate-100 dark:bg-slate-800 px-1.5 py-0.5 rounded transition-colors group-hover/info:text-slate-600 dark:group-hover/info:text-slate-300">{{ $catName }}</span>
@if(($companySettings['enable_material_code'] ?? false) && isset($product->metadata['material_code']))
<span class="text-[10px] font-bold text-emerald-500/80 uppercase tracking-widest bg-emerald-500/10 px-1.5 py-0.5 rounded border border-emerald-500/20">#{{ $product->metadata['material_code'] }}</span>
@endif
</div>
</div>
</div>
</td>
<td class="px-6 py-6 whitespace-nowrap">
<span class="text-sm font-mono font-black text-slate-700 dark:text-slate-200 tracking-tight">{{ $product->barcode ?: '-' }}</span>
</td>
@if(auth()->user()->isSystemAdmin())
<td class="px-6 py-6 text-center">
<span class="text-xs font-bold text-slate-600 dark:text-slate-400 group-hover:text-slate-900 dark:group-hover:text-slate-200 transition-colors">{{ $product->company->name ?? '-' }}</span>
</td>
@endif
<td class="px-6 py-6 text-center whitespace-nowrap">
<span class="text-sm font-black text-slate-800 dark:text-white">${{ number_format($product->price, 0) }}</span>
</td>
<td class="px-6 py-6 text-center whitespace-nowrap">
<span class="text-sm font-black text-indigo-500 dark:text-indigo-400">{{ $product->track_limit }}</span>
<span class="text-xs font-bold text-slate-400 mx-1">/</span>
<span class="text-sm font-black text-amber-500 dark:text-amber-400">{{ $product->spring_limit }}</span>
</td>
<td class="px-6 py-6 text-center">
@if($product->is_active)
<span class="inline-flex items-center px-3 py-1 rounded-full text-[11px] font-black bg-emerald-500/10 text-emerald-500 border border-emerald-500/20 tracking-widest uppercase">{{ __('Active') }}</span>
@else
<span class="inline-flex items-center px-3 py-1 rounded-full text-[11px] font-black bg-rose-500/10 text-rose-500 border border-rose-500/20 tracking-widest uppercase">{{ __('Disabled') }}</span>
@endif
</td>
<td class="px-6 py-6 text-right">
<div class="flex justify-end items-center gap-2">
@if($product->is_active)
<button type="button"
@click="toggleFormAction = '{{ route($baseRoute . '.status.toggle', $product->id) }}'; isStatusConfirmOpen = true"
class="p-2.5 rounded-lg bg-slate-50 dark:bg-slate-800 text-slate-400 hover:text-amber-500 hover:bg-amber-500/5 transition-all border border-transparent hover:border-amber-500/20"
title="{{ __('Disable') }}">
<svg class="size-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="M15.75 5.25v13.5m-7.5-13.5v13.5" /></svg>
</button>
@else
<button type="button"
@click="toggleFormAction = '{{ route($baseRoute . '.status.toggle', $product->id) }}'; $nextTick(() => $refs.statusToggleForm.submit())"
class="p-2.5 rounded-lg bg-slate-50 dark:bg-slate-800 text-slate-400 hover:text-emerald-500 hover:bg-emerald-500/5 transition-all border border-transparent hover:border-emerald-500/20"
title="{{ __('Enable') }}">
<svg class="size-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="M5.25 5.653c0-.856.917-1.398 1.667-.986l11.54 6.348a1.125 1.125 0 010 1.971l-11.54 6.347c-.75.412-1.667-.13-1.667-.986V5.653z" /></svg>
</button>
@endif
<a href="{{ route($baseRoute . '.edit', $product->id) }}" class="p-2.5 rounded-lg bg-slate-50 dark:bg-slate-800 text-slate-400 hover:text-cyan-500 hover:bg-cyan-500/5 transition-all border border-transparent hover:border-cyan-500/20" title="{{ __('Edit') }}">
<svg class="size-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L10.582 16.07a4.5 4.5 0 0 1-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 0 1 1.13-1.897l8.932-8.931Zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0 1 15.75 21H5.25A2.25 2.25 0 0 1 3 18.75V8.25A2.25 2.25 0 0 1 5.25 6H10" /></svg>
</a>
<button type="button" @click="confirmDelete('{{ route($baseRoute . '.destroy', $product->id) }}')" class="p-2.5 rounded-lg bg-slate-50 dark:bg-slate-800 text-slate-400 hover:text-rose-500 hover:bg-rose-500/5 transition-all border border-transparent hover:border-rose-500/20" title="{{ __('Delete') }}">
<svg class="size-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="m14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0" /></svg>
</button>
<button type="button" @click="viewProductDetail(@js($product))" class="p-2.5 rounded-lg bg-slate-50 dark:bg-slate-800 text-slate-400 hover:text-indigo-500 hover:bg-indigo-500/5 transition-all border border-transparent hover:border-indigo-500/20" title="{{ __('View Details') }}">
<svg class="size-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="M2.036 12.322a1.012 1.012 0 010-.644C3.67 8.5 7.652 5 12 5c4.418 0 8.401 3.5 10.014 6.722a1.012 1.012 0 010 .644C20.33 15.5 16.348 19 12 19c-4.412 0-8.401-3.5-10.014-6.722z" /><path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /></svg>
</button>
</div>
</td>
</tr>
@empty
<tr>
<td colspan="5" class="px-6 py-32 text-center text-slate-400 italic">{{ __('No products found matching your criteria.') }}</td>
</tr>
@endforelse
</tbody>
</table>
</div>
<!-- Pagination -->
<div class="mt-8">
{{ $products->links() }}
<div id="tab-products-container" class="relative">
@include('admin.products.partials.tab-products')
</div>
</div>
<!-- Category Tab -->
<div x-show="activeTab === 'categories'" class="luxury-card rounded-3xl p-8 animate-luxury-in" 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>
<div class="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-[0.15em] border-b border-slate-100 dark:border-slate-800">{{ __('Category Name') }}</th>
@if(auth()->user()->isSystemAdmin())
<th class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800 text-center">{{ __('Company') }}</th>
@endif
<th class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800 text-right">{{ __('Actions') }}</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-50 dark:divide-slate-800/80">
@forelse($categories as $category)
<tr class="group hover:bg-slate-50/80 dark:hover:bg-slate-800/40 transition-all duration-300">
<td class="px-6 py-6">
<div class="flex items-center gap-x-4">
<div class="w-10 h-10 rounded-xl bg-slate-100 dark:bg-slate-800 flex items-center justify-center text-slate-400 border border-slate-200 dark:border-slate-700 group-hover:bg-cyan-500 group-hover:text-white group-hover:border-cyan-500 shadow-sm group-hover:shadow-cyan-500/50 transition-all duration-300">
<svg class="size-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M9.568 3H5.25A2.25 2.25 0 003 5.25v13.5A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V9.432a2.25 2.25 0 00-.659-1.591l-4.182-4.182A2.25 2.25 0 0014.568 3h-5z" />
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12h-6m6 4h-6m6-8h-6" />
<div x-show="activeTab === 'categories'"
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 (Categories) -->
<div x-show="tabLoading === 'categories'" x-transition:enter="transition ease-out duration-300"
x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100"
x-transition:leave="transition ease-in duration-200" x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
class="absolute inset-0 z-20 bg-white/40 dark:bg-slate-900/40 backdrop-blur-[1px] flex flex-col items-center justify-center rounded-3xl"
x-cloak>
<div class="relative w-16 h-16 mb-4 flex items-center justify-center">
<div class="absolute inset-0 rounded-full border-2 border-transparent border-t-cyan-500 border-r-cyan-500/30 animate-spin"></div>
<div class="absolute inset-2 rounded-full border border-cyan-500/10 animate-spin" style="animation-duration: 3s; direction: reverse;"></div>
<div class="relative w-8 h-8 flex items-center justify-center">
<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="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
</div>
<span class="text-base font-extrabold text-slate-800 dark:text-slate-100 transition-colors group-hover:text-cyan-600 dark:group-hover:text-cyan-400">
{{ $category->localized_name }}
</span>
</div>
</td>
@if(auth()->user()->isSystemAdmin())
<td class="px-6 py-6 text-center">
<span class="text-xs font-bold text-slate-500 dark:text-slate-400">{{ $category->company->name ?? __('System Default') }}</span>
</td>
@endif
<td class="px-6 py-6 text-right">
<div class="flex justify-end items-center gap-2">
<button type="button" @click="openCategoryModal(@js($category))" class="p-2.5 rounded-lg bg-slate-50 dark:bg-slate-800 text-slate-400 hover:text-cyan-500 hover:bg-cyan-500/5 transition-all border border-transparent hover:border-cyan-500/20" title="{{ __('Edit') }}">
<svg class="size-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L10.582 16.07a4.5 4.5 0 0 1-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 0 1 1.13-1.897l8.932-8.931Zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0 1 15.75 21H5.25A2.25 2.25 0 0 1 3 18.75V8.25A2.25 2.25 0 0 1 5.25 6H10" /></svg>
</button>
<button type="button" @click="confirmDelete('{{ route('admin.data-config.product-categories.destroy', $category->id) }}')" class="p-2.5 rounded-lg bg-slate-50 dark:bg-slate-800 text-slate-400 hover:text-rose-500 hover:bg-rose-500/5 transition-all border border-transparent hover:border-rose-500/20" title="{{ __('Delete') }}">
<svg class="size-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="m14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0" /></svg>
</button>
</div>
</td>
</tr>
@empty
<tr class="animate-luxury-in">
<td colspan="{{ auth()->user()->isSystemAdmin() ? 3 : 2 }}" class="px-6 py-20 text-center">
<div class="flex flex-col items-center justify-center space-y-4">
<div class="w-16 h-16 rounded-3xl bg-slate-50 dark:bg-slate-800/50 flex items-center justify-center text-slate-300 dark:text-slate-700 shadow-inner">
<svg class="size-8" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M20.25 7.5l-.625 10.632a2.25 2.25 0 01-2.247 2.118H6.622a2.25 2.25 0 01-2.247-2.118L3.75 7.5M10 11.25h4M3.375 7.5h17.25c.621 0 1.125-.504 1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125H3.375c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125z" /></svg>
</div>
<p class="text-slate-400 font-bold tracking-widest text-sm uppercase">{{ __('No categories found.') }}</p>
</div>
</td>
</tr>
@endforelse
</tbody>
</table>
</div>
<p class="text-[10px] font-black text-cyan-600 dark:text-cyan-400 uppercase tracking-[0.4em] animate-pulse">
{{ __('Loading Data') }}...</p>
</div>
<!-- Delete Confirm Modal -->
<x-delete-confirm-modal
:title="__('Delete Product Confirmation')"
:message="__('Are you sure you want to delete this product? All related historical translation data will also be removed.')"
/>
<!-- Status Toggle Modal -->
<x-status-confirm-modal
:title="__('Disable Product Confirmation')"
:message="__('Are you sure you want to change the status of this product? Disabled products will not be visible on the machine.')"
/>
<form x-ref="statusToggleForm" :action="toggleFormAction" method="POST" class="hidden">
@csrf
@method('PATCH')
</form>
<form x-ref="deleteForm" :action="deleteFormAction" method="POST" class="hidden">
@csrf
@method('DELETE')
</form>
<div id="tab-categories-container" class="relative">
@include('admin.products.partials.tab-categories')
</div>
</div>
</div>
<!-- Category Modal -->
<div x-show="isCategoryModalOpen" class="fixed inset-0 z-[110] overflow-y-auto" x-cloak>
@@ -534,6 +384,21 @@ $roleSelectConfig = [
</div>
</div>
<!-- Modals -->
<x-delete-confirm-modal
:title="__('Confirm Deletion')"
:message="__('Are you sure you want to delete this product or category? This action cannot be undone.')"
/>
<x-status-confirm-modal
:title="__('Confirm Status Change')"
:message="__('Are you sure you want to change the status of this item? This will affect its visibility on vending machines.')"
/>
<form x-ref="statusToggleForm" :action="toggleFormAction" method="POST" class="hidden">
@csrf
@method('PATCH')
</form>
<!-- Image Zoom Modal -->
<div x-show="isImageZoomed"
x-transition:enter="ease-out duration-300"
@@ -570,28 +435,32 @@ $roleSelectConfig = [
isDetailOpen: false,
isImageZoomed: false,
isStatusConfirmOpen: false,
isDeleteConfirmOpen: false,
isCategoryModalOpen: false,
activeTab: '{{ request("tab", "products") }}',
tabLoading: null,
loading: false,
categoryModalMode: 'create',
categoryFormAction: '',
deleteFormAction: '',
toggleFormAction: '',
selectedProduct: null,
categories: [],
companies: [],
categoryFormFields: {
names: { zh_TW: '', en: '', ja: '' },
company_id: ''
},
submitConfirmedForm() {
this.$refs.statusToggleForm.submit();
},
init() {
this.categories = JSON.parse(this.$el.dataset.categories || '[]');
this.companies = @js($companies);
// Watch for category modal opening to sync searchable select
// Initial binding
this.bindPaginationLinks('#tab-products-container', 'products');
this.bindPaginationLinks('#tab-categories-container', 'categories');
// Watch for category modal updates
this.$watch('isCategoryModalOpen', (value) => {
if (value && document.getElementById('category_company_select_wrapper')) {
this.$nextTick(() => {
@@ -599,6 +468,154 @@ $roleSelectConfig = [
});
}
});
// Sync top loading bar
this.$watch('tabLoading', (val) => {
const bar = document.getElementById('top-loading-bar');
if (bar) {
if (val) bar.classList.add('loading');
else bar.classList.remove('loading');
}
});
// Sync global loading to top bar as well
this.$watch('loading', (val) => {
const bar = document.getElementById('top-loading-bar');
if (bar) {
if (val) bar.classList.add('loading');
else if (!this.tabLoading) 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);
});
},
async fetchTabData(tab, url = null) {
this.tabLoading = tab;
const container = document.getElementById('tab-' + tab + '-container');
// If no URL is provided, build one from the current tab's form
if (!url) {
const form = container?.querySelector('form');
// Start with a clean set of parameters, only keeping the current tab
let params = new URLSearchParams();
params.set('tab', tab);
params.set('_ajax', '1');
if (form) {
const formData = new FormData(form);
formData.forEach((value, key) => {
if (value.trim() !== '') {
params.set(key, value);
}
});
}
url = `${window.location.pathname}?${params.toString()}`;
} else {
// Ensure URL has tab and _ajax params
const urlObj = new URL(url, window.location.origin);
urlObj.searchParams.set('tab', tab);
urlObj.searchParams.set('_ajax', '1');
url = urlObj.toString();
}
try {
const response = await fetch(url, {
headers: {
'X-Requested-With': 'XMLHttpRequest',
'Accept': 'application/json'
}
});
const data = await response.json();
if (data.success) {
if (container) {
container.innerHTML = data.html;
// Re-init Alpine components in the dynamic content
if (window.Alpine) {
window.Alpine.initTree(container);
}
// Re-bind pagination events
this.bindPaginationLinks('#tab-' + tab + '-container', tab);
}
// Update browser URL (without _ajax)
const historyUrl = new URL(url, window.location.origin);
historyUrl.searchParams.delete('_ajax');
window.history.pushState({}, '', historyUrl);
}
} catch (error) {
console.error('Fetch error:', error);
if (window.showToast) window.showToast('{{ __("Failed to load data") }}', 'error');
} finally {
this.tabLoading = null;
}
},
handleFilterSubmit(tab) {
// Clear page when searching
const url = new URL(window.location.href);
const pageKey = tab === 'products' ? 'product_page' : 'category_page';
url.searchParams.delete(pageKey);
this.fetchTabData(tab);
},
bindPaginationLinks(containerSelector, tab) {
this.$nextTick(() => {
const container = document.querySelector(containerSelector);
if (!container) return;
// Re-init Preline components (Selects etc.)
if (window.HSStaticMethods && window.HSStaticMethods.autoInit) {
window.HSStaticMethods.autoInit();
}
// 1. Intercept Standard Links
container.querySelectorAll('nav a, .pagination a').forEach(link => {
// Prevent multiple bindings
if (link.dataset.ajaxBound) return;
link.dataset.ajaxBound = 'true';
link.addEventListener('click', (e) => {
e.preventDefault();
this.fetchTabData(tab, link.href);
});
});
// 2. Intercept Dropdown Changes (Per Page / Page Jump)
container.querySelectorAll('select[onchange]').forEach(sel => {
const originalOnchange = sel.getAttribute('onchange');
if (originalOnchange) {
sel.removeAttribute('onchange'); // Prevent default behavior
sel.addEventListener('change', (e) => {
let newUrl;
// Simple page jump: value is usually the URL
if (sel.value.includes('?')) {
newUrl = sel.value;
} else {
// 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';
params.delete(pageKey); // Reset to page 1
newUrl = window.location.pathname + '?' + params.toString();
}
this.fetchTabData(tab, newUrl);
});
}
});
});
},
updateCategoryCompanySelect() {
@@ -627,7 +644,6 @@ $roleSelectConfig = [
selectEl.setAttribute('data-hs-select', JSON.stringify(config));
// Default System option
const defaultOpt = document.createElement('option');
defaultOpt.value = "";
defaultOpt.textContent = "{{ __('System Default (Common)') }}";
@@ -635,7 +651,6 @@ $roleSelectConfig = [
if (!this.categoryFormFields.company_id) defaultOpt.selected = true;
selectEl.appendChild(defaultOpt);
// Company options
this.companies.forEach(company => {
const opt = document.createElement('option');
opt.value = company.id;
@@ -658,10 +673,7 @@ $roleSelectConfig = [
});
},
confirmDelete(action) {
this.deleteFormAction = action;
this.isDeleteConfirmOpen = true;
},
viewProductDetail(product) {
this.selectedProduct = product;
@@ -690,6 +702,84 @@ $roleSelectConfig = [
this.isCategoryModalOpen = true;
},
confirmDelete(action) {
this.deleteFormAction = action;
this.isDeleteConfirmOpen = true;
},
toggleStatus(actionUrl) {
this.toggleFormAction = actionUrl;
this.isStatusConfirmOpen = true;
},
async submitConfirmedForm() {
if (this.isStatusConfirmOpen) {
this.isStatusConfirmOpen = false;
this.loading = true;
try {
const response = await fetch(this.toggleFormAction, {
method: 'POST',
headers: {
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').getAttribute('content'),
'Accept': 'application/json',
'Content-Type': 'application/x-www-form-urlencoded',
'X-Requested-With': 'XMLHttpRequest'
},
body: new URLSearchParams({
'_method': 'PATCH'
})
});
const data = await response.json();
if (data.success) {
await this.fetchTabData(this.activeTab);
if (window.showToast) window.showToast(data.message, 'success');
} else {
if (window.showToast) window.showToast(data.message || 'Error', 'error');
}
} catch (error) {
console.error('Status toggle error:', error);
if (window.showToast) window.showToast('{{ __("Operation failed") }}', 'error');
} finally {
this.loading = false;
}
}
},
confirmDelete(actionUrl) {
this.deleteFormAction = actionUrl;
this.isDeleteConfirmOpen = true;
},
async deleteItem() {
this.isDeleteConfirmOpen = false;
this.loading = true;
try {
const response = await fetch(this.deleteFormAction, {
method: 'POST',
headers: {
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').getAttribute('content'),
'Accept': 'application/json',
'X-Requested-With': 'XMLHttpRequest'
},
body: new URLSearchParams({
'_method': 'DELETE'
})
});
const data = await response.json();
if (data.success) {
await this.fetchTabData(this.activeTab);
if (window.showToast) window.showToast(data.message, 'success');
} else {
if (window.showToast) window.showToast(data.message || 'Error', 'error');
}
} catch (error) {
console.error('Delete error:', error);
if (window.showToast) window.showToast('{{ __("Operation failed") }}', 'error');
} finally {
this.loading = false;
}
},
getCategoryName(id) {
const category = this.categories.find(c => c.id == id);
return category ? (category.name || "{{ __('Uncategorized') }}") : "{{ __('Uncategorized') }}";

View File

@@ -0,0 +1,84 @@
<div class="relative">
<form @submit.prevent="handleFilterSubmit('categories')" id="categories-filter-form" class="mb-8 flex flex-wrap items-center gap-4">
<!-- Search Input -->
<div class="relative group">
<div class="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none text-slate-400 group-focus-within:text-cyan-500 transition-colors">
<svg class="size-4" 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>
</div>
<input type="text" name="category_search" value="{{ request('category_search', request('tab') === 'categories' ? request('category_search') : '') }}"
class="bg-slate-50 dark:bg-slate-900/50 border-none rounded-xl py-3 pl-10 pr-4 text-sm font-bold text-slate-700 dark:text-slate-200 focus:ring-2 focus:ring-cyan-500/20 w-64 transition-all shadow-sm"
placeholder="{{ __('Search categories...') }}">
</div>
@if(auth()->user()->isSystemAdmin())
<div class="relative min-w-[200px]">
<x-searchable-select name="category_company_id"
:options="$companies"
:selected="request('category_company_id')"
:placeholder="__('All Companies')"
@change="handleFilterSubmit('categories')"
/>
</div>
@endif
<input type="hidden" name="tab" value="categories">
</form>
<div class="overflow-x-auto luxury-scrollbar">
<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-[0.15em] border-b border-slate-100 dark:border-slate-800">{{ __('Category Name') }}</th>
@if(auth()->user()->isSystemAdmin())
<th class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800 text-center">{{ __('Company') }}</th>
@endif
<th class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800 text-right">{{ __('Actions') }}</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-50 dark:divide-slate-800/80">
@forelse($categories as $category)
<tr class="group hover:bg-slate-50/80 dark:hover:bg-slate-800/40 transition-all duration-300">
<td class="px-6 py-5">
<div class="flex items-center gap-x-4">
<div class="w-10 h-10 rounded-xl bg-slate-100 dark:bg-slate-800 flex items-center justify-center text-slate-400 border border-slate-200 dark:border-slate-700 group-hover:bg-cyan-500 group-hover:text-white group-hover:border-cyan-500 shadow-sm group-hover:shadow-cyan-500/50 transition-all duration-300">
<svg class="size-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M9.568 3H5.25A2.25 2.25 0 003 5.25v13.5A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V9.432a2.25 2.25 0 00-.659-1.591l-4.182-4.182A2.25 2.25 0 0014.568 3h-5z" />
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12h-6m6 4h-6m6-8h-6" />
</svg>
</div>
<span class="text-base font-extrabold text-slate-800 dark:text-slate-100 transition-colors group-hover:text-cyan-600 dark:group-hover:text-cyan-400">
{{ $category->localized_name }}
</span>
</div>
</td>
@if(auth()->user()->isSystemAdmin())
<td class="px-6 py-5 text-center">
<span class="text-xs font-black text-slate-400 dark:text-slate-500 uppercase tracking-widest">{{ $category->company->name ?? __('System Default') }}</span>
</td>
@endif
<td class="px-6 py-5 text-right">
<div class="flex justify-end items-center gap-2">
<button type="button" @click="openCategoryModal(@js($category))" class="p-2.5 rounded-lg bg-slate-50 dark:bg-slate-800 text-slate-400 hover:text-cyan-500 hover:bg-cyan-500/5 transition-all border border-transparent hover:border-cyan-500/20" title="{{ __('Edit') }}">
<svg class="size-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L10.582 16.07a4.5 4.5 0 0 1-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 0 1 1.13-1.897l8.932-8.931Zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0 1 15.75 21H5.25A2.25 2.25 0 0 1 3 18.75V8.25A2.25 2.25 0 0 1 5.25 6H10" /></svg>
</button>
<button type="button" @click="confirmDelete('{{ route('admin.data-config.product-categories.destroy', $category->id) }}')" class="p-2.5 rounded-lg bg-slate-50 dark:bg-slate-800 text-slate-400 hover:text-rose-500 hover:bg-rose-500/5 transition-all border border-transparent hover:border-rose-500/20" title="{{ __('Delete') }}">
<svg class="size-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="m14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0" /></svg>
</button>
</div>
</td>
</tr>
@empty
<tr>
<td colspan="{{ auth()->user()->isSystemAdmin() ? 3 : 2 }}" class="px-6 py-20 text-center text-slate-400 italic font-bold tracking-widest small-caps">
{{ __('No categories found.') }}
</td>
</tr>
@endforelse
</tbody>
</table>
</div>
<!-- Pagination -->
<div class="mt-6 py-6 border-t border-slate-50 dark:border-slate-800/50">
{{ $categories->links('vendor.pagination.luxury') }}
</div>
</div>

View File

@@ -0,0 +1,144 @@
@php
$baseRoute = 'admin.data-config.products';
@endphp
<div class="relative">
<!-- Filters & Search -->
<form @submit.prevent="handleFilterSubmit('products')" id="products-filter-form" class="flex flex-col md:flex-row md:items-center gap-4 mb-6">
<div class="relative group">
<button type="submit" class="absolute inset-y-0 left-0 flex items-center pl-4 z-10 text-slate-400 group-focus-within:text-cyan-500 transition-colors">
<svg class="size-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<circle cx="11" cy="11" r="8"></circle><line x1="21" y1="21" x2="16.65" y2="16.65"></line>
</svg>
</button>
<input type="text" name="product_search" value="{{ request('product_search') }}"
class="py-2.5 pl-12 pr-6 block w-full md:w-80 luxury-input" placeholder="{{ __('Search products...') }}">
<button type="submit" class="hidden"></button>
</div>
@if(auth()->user()->isSystemAdmin())
<div class="relative min-w-[200px]">
<x-searchable-select name="product_company_id" :options="$companies" :selected="request('product_company_id')" :placeholder="__('All Companies')" @change="handleFilterSubmit('products')" />
</div>
@endif
<input type="hidden" name="tab" value="products">
</form>
<!-- Table -->
<div class="overflow-x-auto luxury-scrollbar">
<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-[0.15em] border-b border-slate-100 dark:border-slate-800">{{ __('Product Info') }}</th>
<th class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800">{{ __('Barcode') }}</th>
@if(auth()->user()->isSystemAdmin())
<th class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800 text-center">{{ __('Company') }}</th>
@endif
<th class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800 text-center">{{ __('Sale Price') }}</th>
<th class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800 text-center">{{ __('Track Limit (Track/Spring)') }}</th>
<th class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800 text-center">{{ __('Status') }}</th>
<th class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800 text-right">{{ __('Actions') }}</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-50 dark:divide-slate-800/80">
@forelse($products as $product)
<tr class="group hover:bg-slate-50/80 dark:hover:bg-slate-800/40 transition-all duration-300">
<td class="px-6 py-5 cursor-pointer group/info" @click="viewProductDetail(@js($product))" title="{{ __('View Details') }}">
<div class="flex items-center gap-x-4">
<div class="w-12 h-12 rounded-xl bg-slate-100 dark:bg-slate-800 flex items-center justify-center text-slate-400 border border-slate-200 dark:border-slate-700 group-hover/info:bg-cyan-500 group-hover/info:text-white group-hover/info:border-cyan-500 shadow-sm group-hover/info:shadow-cyan-500/50 transition-all duration-300 overflow-hidden relative">
@if($product->image_url)
<img src="{{ $product->image_url }}" class="w-full h-full object-cover">
@else
<svg class="size-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="m21 7.5-9-5.25L3 7.5m18 0-9 5.25m9-5.25v9l-9 5.25M3 7.5l9 5.25M3 7.5v9l9 5.25m0-9v9" /></svg>
@endif
</div>
<div class="flex flex-col">
<span class="text-base font-extrabold text-slate-800 dark:text-slate-100 group-hover/info:text-cyan-600 dark:group-hover/info:text-cyan-400 transition-colors leading-tight">{{ $product->localized_name }}</span>
<div class="flex flex-wrap items-center gap-1.5 mt-1.5">
@php
$catName = $product->category->localized_name ?? __('Uncategorized');
@endphp
<span class="text-[10px] font-black text-slate-500 dark:text-slate-400 uppercase tracking-[0.14em] bg-slate-100/80 dark:bg-slate-800/80 border border-slate-200/60 dark:border-slate-700/60 px-2.5 py-1 rounded-lg backdrop-blur-sm transition-all duration-300 group-hover/info:bg-cyan-500/10 group-hover/info:border-cyan-500/20 group-hover/info:text-cyan-500 group-hover/info:shadow-sm group-hover/info:shadow-cyan-500/10">{{ $catName }}</span>
@if(($companySettings['enable_material_code'] ?? false) && isset($product->metadata['material_code']))
<span class="text-[10px] font-black text-emerald-500/90 uppercase tracking-widest bg-emerald-500/10 px-2 py-0.5 rounded-lg border border-emerald-500/20 shadow-sm shadow-emerald-500/5">#{{ $product->metadata['material_code'] }}</span>
@endif
</div>
</div>
</div>
</td>
<td class="px-6 py-5 whitespace-nowrap">
<span class="text-sm font-mono font-bold text-slate-600 dark:text-slate-300 tracking-tight">{{ $product->barcode ?: '-' }}</span>
</td>
@if(auth()->user()->isSystemAdmin())
<td class="px-6 py-5 text-center">
<span class="text-xs font-black text-slate-400 dark:text-slate-500 uppercase tracking-widest group-hover:text-slate-600 dark:group-hover:text-slate-300 transition-colors">{{ $product->company->name ?? '-' }}</span>
</td>
@endif
<td class="px-6 py-5 text-center whitespace-nowrap">
<span class="text-sm font-black text-slate-800 dark:text-white leading-none">${{ number_format($product->price, 0) }}</span>
</td>
<td class="px-6 py-5">
<div class="flex items-center justify-center gap-2 font-black">
<span class="text-base text-indigo-500 dark:text-indigo-400">
{{ $product->track_limit ?: 0 }}
</span>
<span class="text-xs text-slate-300 dark:text-slate-700">/</span>
<span class="text-base text-amber-500 dark:text-amber-500">
{{ $product->spring_limit ?: 0 }}
</span>
</div>
</td>
<td class="px-6 py-5 text-center">
@if($product->is_active)
<span class="inline-flex items-center px-3 py-1.5 rounded-xl text-[10px] font-black bg-emerald-500/10 text-emerald-500 border border-emerald-500/20 tracking-widest uppercase shadow-sm shadow-emerald-500/5">{{ __('Active') }}</span>
@else
<span class="inline-flex items-center px-3 py-1.5 rounded-xl text-[10px] font-black bg-slate-400/10 text-slate-400 border border-slate-400/20 tracking-widest uppercase">{{ __('Disabled') }}</span>
@endif
</td>
<td class="px-6 py-5 text-right">
<div class="flex justify-end items-center gap-2">
@if($product->is_active)
<button type="button"
@click="toggleStatus('{{ route($baseRoute . '.status.toggle', $product->id) }}')"
class="p-2.5 rounded-lg bg-slate-50 dark:bg-slate-800 text-slate-400 hover:text-amber-500 hover:bg-amber-500/5 transition-all border border-transparent hover:border-amber-500/20"
title="{{ __('Disable') }}">
<svg class="size-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="M15.75 5.25v13.5m-7.5-13.5v13.5" /></svg>
</button>
@else
<button type="button"
@click="toggleStatus('{{ route($baseRoute . '.status.toggle', $product->id) }}')"
class="p-2.5 rounded-lg bg-slate-50 dark:bg-slate-800 text-slate-400 hover:text-emerald-500 hover:bg-emerald-500/5 transition-all border border-transparent hover:border-emerald-500/20"
title="{{ __('Enable') }}">
<svg class="size-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="M5.25 5.653c0-.856.917-1.398 1.667-.986l11.54 6.348a1.125 1.125 0 010 1.971l-11.54 6.347c-.75.412-1.667-.13-1.667-.986V5.653z" /></svg>
</button>
@endif
<a href="{{ route($baseRoute . '.edit', $product->id) }}" class="p-2.5 rounded-lg bg-slate-50 dark:bg-slate-800 text-slate-400 hover:text-cyan-500 hover:bg-cyan-500/5 transition-all border border-transparent hover:border-cyan-500/20" title="{{ __('Edit') }}">
<svg class="size-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L10.582 16.07a4.5 4.5 0 0 1-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 0 1 1.13-1.897l8.932-8.931Zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0 1 15.75 21H5.25A2.25 2.25 0 0 1 3 18.75V8.25A2.25 2.25 0 0 1 5.25 6H10" /></svg>
</a>
<button type="button" @click="confirmDelete('{{ route($baseRoute . '.destroy', $product->id) }}')" class="p-2.5 rounded-lg bg-slate-50 dark:bg-slate-800 text-slate-400 hover:text-rose-500 hover:bg-rose-500/5 transition-all border border-transparent hover:border-rose-500/20" title="{{ __('Delete') }}">
<svg class="size-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="m14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0" /></svg>
</button>
<button type="button" @click="viewProductDetail({{ json_encode($product) }})" class="p-2.5 rounded-lg bg-slate-50 dark:bg-slate-800 text-slate-400 hover:text-indigo-500 hover:bg-indigo-500/5 transition-all border border-transparent hover:border-indigo-500/20" title="{{ __('View') }}">
<svg class="size-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M2.036 12.322a1.012 1.012 0 0 1 0-.644C3.67 8.5 7.652 6 12 6c4.348 0 8.33 2.5 9.964 5.678.134.263.134.561 0 .824C20.33 15.5 16.348 18 12 18c-4.348 0-8.33-2.5-9.964-5.678Z" />
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" />
</svg>
</button>
</div>
</td>
</tr>
@empty
<tr>
<td colspan="7" class="px-6 py-20 text-center text-slate-400 italic font-bold tracking-widest small-caps">{{ __('No products found matching your criteria.') }}</td>
</tr>
@endforelse
</tbody>
</table>
</div>
<!-- Pagination -->
<div class="mt-6 py-6 border-t border-slate-50 dark:border-slate-800/50">
{{ $products->links('vendor.pagination.luxury') }}
</div>
</div>

View File

@@ -22,6 +22,7 @@
"searchClasses" => "block w-[calc(100%-16px)] mx-2 py-2 px-3 text-sm border-slate-200 dark:border-white/10 rounded-lg focus:border-cyan-500 focus:ring-cyan-500 bg-slate-50 dark:bg-slate-900/50 dark:text-slate-200 placeholder:text-slate-400 dark:placeholder:text-slate-500",
"searchWrapperClasses" => "sticky top-0 bg-white/95 dark:bg-slate-900/95 backdrop-blur-md p-2 z-10",
"toggleClasses" => "hs-select-toggle luxury-select-toggle",
"toggleTemplate" => '<button type="button" aria-expanded="false"><span class="me-2" data-icon></span><span class="text-slate-800 dark:text-slate-200" data-title></span><div class="ms-auto"><svg class="size-4 text-slate-400 transition-transform duration-300" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9 6 6 6-6"/></svg></div></button>',
"dropdownClasses" => "hs-select-menu w-full bg-white dark:bg-slate-900 border border-slate-200 dark:border-white/10 rounded-xl shadow-[0_20px_50px_rgba(0,0,0,0.3)] mt-2 z-[100] animate-luxury-in",
"optionClasses" => "hs-select-option py-2.5 px-3 mb-0.5 text-sm text-slate-800 dark:text-slate-300 cursor-pointer hover:bg-slate-100 dark:hover:bg-cyan-500/10 dark:hover:text-cyan-400 rounded-lg flex items-center justify-between transition-all duration-300",
"optionTemplate" => '<div class="flex items-center justify-between w-full"><span data-title></span><span class="hs-select-active-indicator hidden text-cyan-500"><svg class="w-4 h-4" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"></polyline></svg></span></div>'

View File

@@ -11,13 +11,13 @@
$limits = [10, 25, 50, 100];
@endphp
<select onchange="const params = new URLSearchParams(window.location.search); params.set('per_page', this.value); params.delete('page'); window.location.href = window.location.pathname + '?' + params.toString();"
class="h-7 pl-2 pr-8 rounded-lg bg-white dark:bg-slate-800 border-none text-[11px] font-black text-slate-600 dark:text-slate-300 appearance-none focus:ring-4 focus:ring-cyan-500/10 outline-none transition-all cursor-pointer shadow-sm leading-none py-0">
class="h-7 pl-2 pr-7 rounded-lg bg-white dark:bg-slate-800 border-none text-[11px] font-black text-slate-600 dark:text-slate-300 appearance-none focus:ring-4 focus:ring-cyan-500/10 outline-none transition-all cursor-pointer shadow-sm leading-none py-0 !bg-none">
@foreach($limits as $l)
<option value="{{ $l }}" {{ $currentLimit == $l ? 'selected' : '' }}>{{ $l }}</option>
@endforeach
</select>
<div class="absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none opacity-40 group-hover:opacity-100 transition-opacity">
<svg class="size-3 text-cyan-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M19 9l-7 7-7-7"/></svg>
<div class="absolute inset-y-0 right-0 flex items-center pr-1.5 pointer-events-none text-cyan-500/50 group-hover:text-cyan-500 transition-colors">
<svg class="size-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M19 9l-7 7-7-7"/></svg>
</div>
</div>
</div>
@@ -52,7 +52,7 @@
{{-- Unified Quick Jump Selection (Desktop & Mobile) --}}
<div class="relative group">
<select onchange="window.location.href = this.value"
class="h-9 pl-4 pr-10 rounded-xl bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-700 text-[11px] sm:text-xs font-black text-slate-600 dark:text-slate-300 appearance-none focus:ring-4 focus:ring-cyan-500/10 focus:border-cyan-500 outline-none transition-all cursor-pointer shadow-sm hover:border-slate-300 dark:hover:border-slate-600 {{ $paginator->lastPage() <= 1 ? 'opacity-50 cursor-not-allowed pointer-events-none' : '' }}"
class="h-9 pl-4 pr-10 rounded-xl bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-700 text-[11px] sm:text-xs font-black text-slate-600 dark:text-slate-300 appearance-none focus:ring-4 focus:ring-cyan-500/10 focus:border-cyan-500 outline-none transition-all cursor-pointer shadow-sm hover:border-slate-300 dark:hover:border-slate-600 !bg-none {{ $paginator->lastPage() <= 1 ? 'opacity-50 cursor-not-allowed pointer-events-none' : '' }}"
{{ $paginator->lastPage() <= 1 ? 'disabled' : '' }}>
@for ($i = 1; $i <= $paginator->lastPage(); $i++)
<option value="{{ $paginator->url($i) }}" {{ $i == $paginator->currentPage() ? 'selected' : '' }}>
@@ -60,8 +60,8 @@
</option>
@endfor
</select>
<div class="absolute inset-y-0 right-0 flex items-center pr-3 pointer-events-none opacity-40 group-hover:opacity-100 transition-opacity">
<svg class="size-3.5 text-cyan-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M19 9l-7 7-7-7"/></svg>
<div class="absolute inset-y-0 right-0 flex items-center pr-3 pointer-events-none text-cyan-500/50 group-hover:text-cyan-500 transition-colors">
<svg class="size-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M19 9l-7 7-7-7"/></svg>
</div>
</div>