[FEAT] 移除「商品狀態」冗餘模組、優化麵包屑導航與完善帳號角色過濾邏輯
All checks were successful
star-cloud-deploy-demo / deploy-demo (push) Successful in 46s

This commit is contained in:
2026-03-27 16:53:43 +08:00
parent 740eaa30b7
commit c875ab7d29
15 changed files with 431 additions and 159 deletions

View File

@@ -72,7 +72,7 @@ trigger: always_on
### 5.1 初始角色建立
當系統管理員為新客戶(該租戶尚未有任何角色)建立第一個帳號時,應遵循以下邏輯:
1. **選取範本**:從系統預設的「全域角色範本」(`company_id = null``is_system = 0`)中選取一個作為基礎。
1. **選取範本**:從系統預設的「全域角色範本」(`company_id = null``is_system = 1`)中選取一個作為基礎,但必須排除「超級管理員 (`super-admin`)」
2. **自動克隆**:系統會將該範本的權限內容複製一份至該租戶下。
3. **統一命名**:克隆後的角色名稱在該租戶公司內應統一命名為**「管理員」**。
4. **帳號綁定**:該新客戶帳號將被指派至此新建立的「管理員」角色。

View File

@@ -25,15 +25,6 @@ class DataConfigController extends Controller
]);
}
// 管理者可賣商品
public function adminProducts()
{
return view('admin.placeholder', [
'title' => '商品狀態',
'description' => '管理者商品銷售權限',
]);
}
// 子帳號管理
public function subAccounts()

View File

@@ -315,8 +315,8 @@ class PermissionController extends Controller
// 驗證角色與公司的匹配性 (RBAC Safeguard)
if ($company_id !== null) {
// 如果是租戶帳號,不能選超級管理員角色
if ($role->is_system && $role->name === 'super-admin') {
// 如果是租戶帳號,絕對不能指派超級管理員角色 (super-admin)
if ($role->name === 'super-admin') {
return redirect()->back()->with('error', __('Super-admin role cannot be assigned to tenant accounts.'));
}
// 如果角色有特定的 company_id必須匹配
@@ -324,7 +324,7 @@ class PermissionController extends Controller
return redirect()->back()->with('error', __('This role belongs to another company and cannot be assigned.'));
}
} else {
// 如果是系統層級帳號,只能選系統角色 (is_system = 1)
// 如果是系統層級帳號,只能選全域系統角色 (is_system = 1)
if (!$role->is_system) {
return redirect()->back()->with('error', __('Only system roles can be assigned to platform administrative accounts.'));
}
@@ -408,7 +408,8 @@ class PermissionController extends Controller
// 驗證角色與公司的匹配性 (RBAC Safeguard)
if ($user->id !== auth()->id()) { // 排除編輯自己 (super-admin 有特殊邏輯)
if ($target_company_id !== null) {
if ($roleObj->is_system && $roleObj->name === 'super-admin') {
// 租戶層級排除 super-admin
if ($roleObj->name === 'super-admin') {
return redirect()->back()->with('error', __('Super-admin role cannot be assigned to tenant accounts.'));
}
if ($roleObj->company_id !== null && $roleObj->company_id != $target_company_id) {
@@ -416,7 +417,7 @@ class PermissionController extends Controller
}
} else {
if (!$roleObj->is_system) {
return redirect()->back()->with('error', __('Only system roles can be assigned to platform administrative accounts.'));
return redirect()->back()->with('error', __('Only global system roles can be assigned to platform administrative accounts.'));
}
}
}

View File

@@ -297,6 +297,20 @@ class ProductController extends Controller
}
}
public function toggleStatus($id)
{
try {
$product = Product::findOrFail($id);
$product->is_active = !$product->is_active;
$product->save();
$status = $product->is_active ? __('Enabled') : __('Disabled');
return redirect()->back()->with('success', __('Product status updated to :status', ['status' => $status]));
} catch (\Exception $e) {
return redirect()->back()->with('error', $e->getMessage());
}
}
public function destroy($id)
{
try {

View File

@@ -30,8 +30,12 @@ class RoleSeeder extends Seeder
'menu.analysis',
'menu.audit',
'menu.data-config',
'menu.data-config.products',
'menu.data-config.advertisements',
'menu.data-config.sub-accounts',
'menu.data-config.sub-account-roles',
'menu.data-config.points',
'menu.data-config.badges',
'menu.remote',
'menu.line',
'menu.reservation',
@@ -72,8 +76,12 @@ class RoleSeeder extends Seeder
'menu.analysis',
'menu.audit',
'menu.data-config',
'menu.data-config.products',
'menu.data-config.advertisements',
'menu.data-config.sub-accounts',
'menu.data-config.sub-account-roles',
'menu.data-config.points',
'menu.data-config.badges',
'menu.remote',
'menu.line',
'menu.reservation',

View File

@@ -79,7 +79,6 @@
"Cancel": "Cancel",
"Cancel Purchase": "Cancel Purchase",
"Cannot Delete Role": "Cannot Delete Role",
"Cannot delete company with active accounts.": "Cannot delete company with active accounts.",
"Cannot delete model that is currently in use by machines.": "Cannot delete model that is currently in use by machines.",
"Cannot delete role with active users.": "Cannot delete role with active users.",
"Card Reader": "Card Reader",
@@ -130,8 +129,7 @@
"Customer Info": "Customer Info",
"Customer Management": "Customer Management",
"Customer Payment Config": "Customer Payment Config",
"Customer created successfully.": "Customer created successfully.",
"Customer deleted successfully.": "Customer deleted successfully.",
"Customer created successfully.": "Customer created successfully",
"Customer updated successfully.": "Customer updated successfully.",
"Danger Zone: Delete Account": "Danger Zone: Delete Account",
"Dashboard": "Dashboard",
@@ -173,7 +171,6 @@
"Edit Role": "Edit Role",
"Edit Role Permissions": "Edit Role Permissions",
"Edit Settings": "Edit Settings",
"Edit Sub Account Role": "編輯子帳號角色",
"Email": "Email",
"Enabled/Disabled": "Enabled/Disabled",
"Engineer": "Engineer",
@@ -252,6 +249,7 @@
"Joined": "Joined",
"Key": "Key",
"Key No": "Key No",
"Identity & Codes": "Identity & Codes",
"LEVEL TYPE": "LEVEL TYPE",
"LINE Pay Direct": "LINE Pay Direct",
"LINE Pay Direct Settings Description": "LINE Pay Official Direct Connection Settings",
@@ -272,6 +270,7 @@
"Line Permissions": "Line Permissions",
"Line Products": "Line Products",
"Loading machines...": "Loading machines...",
"Loyalty & Features": "Loyalty & Features",
"Loading...": "Loading...",
"Location": "Location",
"Locked Page": "Locked Page",
@@ -419,7 +418,7 @@
"Payment Selection": "Payment Selection",
"Pending": "Pending",
"Pricing Information": "Pricing Information",
"Performance": "效能 (Performance)",
"Performance": "Performance",
"Permanent": "Permanent",
"Permanently Delete Account": "Permanently Delete Account",
"Permission Settings": "Permission Settings",
@@ -702,6 +701,10 @@
"Account :name status has been changed to :status.": "Account :name status has been changed to :status.",
"Cannot change Super Admin status.": "Cannot change Super Admin status.",
"Confirm Status Change": "Confirm Status Change",
"Disable Product Confirmation": "Disable Product Confirmation",
"Delete Product Confirmation": "Delete Product Confirmation",
"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 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 change the status? This may affect associated accounts.": "Are you sure you want to change the status? This may affect associated accounts.",
"Confirm Account Status Change": "Confirm Account Status Change",
"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.",
@@ -753,5 +756,20 @@
"Customer Details": "Customer Details",
"Current Status": "Current Status",
"Product Image": "Product Image",
"PNG, JPG up to 2MB": "PNG, JPG up to 2MB"
"PNG, JPG up to 2MB": "PNG, JPG up to 2MB",
"menu.data-config.products": "Product Management",
"menu.data-config.advertisements": "Advertisement Management",
"menu.data-config.admin-products": "Product Status",
"menu.data-config.points": "Point Settings",
"menu.data-config.badges": "Badge Settings",
"Create New Role": "Create New Role",
"Create Sub Account Role": "Create Sub Account Role",
"Edit Sub Account Role": "Edit Sub Account Role",
"New Role": "New Role",
"Manufacturer": "Manufacturer",
"Product status updated to :status": "Product status updated to :status",
"Customer and associated accounts disabled successfully.": "Customer and associated accounts disabled successfully.",
"Customer enabled successfully.": "Customer enabled successfully.",
"Cannot delete company with active accounts.": "Cannot delete company with active accounts.",
"Customer deleted successfully.": "Customer deleted successfully."
}

View File

@@ -79,7 +79,6 @@
"Cancel": "キャンセル",
"Cancel Purchase": "購入キャンセル",
"Cannot Delete Role": "ロールを削除できません",
"Cannot delete company with active accounts.": "アクティブなアカウントを持つ会社は削除できません。",
"Cannot delete model that is currently in use by machines.": "機台で使用中の型號は削除できません。",
"Cannot delete role with active users.": "アクティブなユーザーがいるロールは削除できません。",
"Card Reader": "カードリーダー",
@@ -131,7 +130,6 @@
"Customer Management": "顧客管理",
"Customer Payment Config": "決済設定管理",
"Customer created successfully.": "顧客が正常に作成されました。",
"Customer deleted successfully.": "顧客が正常に削除されました。",
"Customer updated successfully.": "顧客が正常に更新されました。",
"Danger Zone: Delete Account": "危険区域:アカウントの削除",
"Dashboard": "ダッシュボード",
@@ -173,7 +171,6 @@
"Edit Role": "ロール編集",
"Edit Role Permissions": "ロール権限の編集",
"Edit Settings": "設定編集",
"Edit Sub Account Role": "編輯子帳號角色",
"Email": "メールアドレス",
"Enabled/Disabled": "有効/無効",
"Engineer": "メンテナンス担当者",
@@ -252,6 +249,7 @@
"Joined": "入会日",
"Key": "キー (Key)",
"Key No": "キー番号",
"Identity & Codes": "識別とコード",
"LEVEL TYPE": "層級タイプ",
"LINE Pay Direct": "LINE Pay 直結決済",
"LINE Pay Direct Settings Description": "LINE Pay 公式直結設定",
@@ -272,6 +270,7 @@
"Line Permissions": "Line管理權限",
"Line Products": "Line商品",
"Loading machines...": "正在載入機台...",
"Loyalty & Features": "ロイヤリティと機能",
"Loading...": "読み込み中...",
"Location": "場所",
"Locked Page": "ロック画面",
@@ -420,7 +419,8 @@
"Payment Selection": "決済選択",
"Pending": "保留中",
"Pricing Information": "価格情報",
"Performance": "效能 (Performance)",
"Channel Limits Configuration": "スロット上限設定",
"Performance": "パフォーマンス (Performance)",
"Permanent": "永久認可",
"Permanently Delete Account": "アカウントを永久に削除",
"Permission Settings": "権限設定",
@@ -707,6 +707,10 @@
"Account :name status has been changed to :status.": "アカウント :name のステータスが :status に変更されました。",
"Cannot change Super Admin status.": "スーパー管理者のステータスは変更できません。",
"Confirm Status Change": "ステータス変更の確認",
"Disable Product Confirmation": "商品無効化の確認",
"Delete Product Confirmation": "商品削除の確認",
"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 delete this product? All related historical translation data will also be removed.": "この商品を削除してもよろしいですか?関連するすべての履歴翻訳データも削除されます。",
"Are you sure you want to change the status? This may affect associated accounts.": "ステータスを変更してもよろしいですか?関連するアカウントに影響する可能性があります。",
"Confirm Account Status Change": "アカウントステータス変更の確認",
"Are you sure you want to change the status? After disabling, this account will no longer be able to log in to the system.": "ステータスを変更してもよろしいですか?無効化後、このアカウントはシステムにログインできなくなります。",
@@ -730,7 +734,6 @@
"Name in Japanese": "日本語名",
"Track Limit": "ベルトコンベア上限",
"Spring Limit": "スプリング上限",
"Channel Limits Configuration": "スロット上限設定",
"Manage your catalog, prices, and multilingual details.": "カタログ、価格、多言語詳細を管理します。",
"Product created successfully": "商品が正常に作成されました",
"Product updated successfully": "商品が正常に更新されました",
@@ -756,5 +759,20 @@
"Enable Points": "ポイントルールを有効化",
"Show points rules in products": "商品情報にポイントルール相關フィールドを表示する",
"Product Image": "商品画像",
"PNG, JPG up to 2MB": "PNG, JPG (最大 2MB)"
"PNG, JPG up to 2MB": "PNG, JPG (最大 2MB)",
"menu.data-config.products": "商品管理",
"menu.data-config.advertisements": "広告管理",
"menu.data-config.admin-products": "商品ステータス",
"menu.data-config.points": "ポイント設定",
"menu.data-config.badges": "バッジ設定",
"Create New Role": "新しいロールを作成",
"Create Sub Account Role": "サブアカウントロールを作成",
"Edit Sub Account Role": "サブアカウントロールを編集",
"New Role": "新しいロール",
"Manufacturer": "製造元",
"Product status updated to :status": "商品ステータスが :status に更新されました",
"Customer and associated accounts disabled successfully.": "顧客と関連アカウントが正常に無効化されました。",
"Customer enabled successfully.": "顧客が正常に有効化されました。",
"Cannot delete company with active accounts.": "有効なアカウントを持つ顧客を削除できません。",
"Customer deleted successfully.": "顧客が正常に削除されました。"
}

View File

@@ -79,7 +79,6 @@
"Cancel": "取消",
"Cancel Purchase": "取消購買",
"Cannot Delete Role": "無法刪除該角色",
"Cannot delete company with active accounts.": "無法刪除仍有帳號的客戶",
"Cannot delete model that is currently in use by machines.": "無法刪除目前正在被機台使用的型號。",
"Cannot delete role with active users.": "無法刪除已有綁定帳號的角色。",
"Card Reader": "刷卡機",
@@ -131,7 +130,6 @@
"Customer Management": "客戶管理",
"Customer Payment Config": "客戶金流設定",
"Customer created successfully.": "客戶新增成功",
"Customer deleted successfully.": "客戶刪除成功",
"Customer updated successfully.": "客戶更新成功",
"Danger Zone: Delete Account": "危險區域:刪除帳號",
"Dashboard": "儀表板",
@@ -173,7 +171,6 @@
"Edit Role": "編輯角色",
"Edit Role Permissions": "編輯角色權限",
"Edit Settings": "編輯設定",
"Edit Sub Account Role": "編輯子帳號角色",
"Email": "電子郵件",
"Enabled/Disabled": "啟用/停用",
"Engineer": "維修人員",
@@ -231,6 +228,7 @@
"Joined": "加入日期",
"Key": "金鑰 (Key)",
"Key No": "鑰匙編號",
"Identity & Codes": "識別與代碼",
"LEVEL TYPE": "層級類型",
"LINE Pay Direct": "LINE Pay 官方直連",
"LINE Pay Direct Settings Description": "LINE Pay 官方直連設定",
@@ -251,6 +249,7 @@
"Line Permissions": "Line 管理權限",
"Line Products": "Line商品",
"Loading machines...": "正在載入機台...",
"Loyalty & Features": "行銷與點數",
"Loading...": "載入中...",
"Location": "位置",
"Locked Page": "鎖定頁",
@@ -707,6 +706,10 @@
"Account :name status has been changed to :status.": "帳號 :name 的狀態已變更為 :status。",
"Cannot change Super Admin status.": "無法變更超級管理員的狀態。",
"Confirm Status Change": "確認變更狀態",
"Disable Product Confirmation": "停用商品確認",
"Delete Product Confirmation": "刪除商品確認",
"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 delete this product? All related historical translation data will also be removed.": "確定要刪除此商品嗎?所有相關的歷史翻譯數據也將被移除。",
"Are you sure you want to change the status? This may affect associated accounts.": "您確定要變更狀態嗎?這可能會影響相關帳號的權限效力。",
"Confirm Account Status Change": "帳號狀態變更確認",
"Are you sure you want to change the status? After disabling, this account will no longer be able to log in to the system.": "您確定要變更狀態嗎?停用之後,該帳號將會立即被登出且無法再登入系統。",
@@ -773,5 +776,20 @@
"Customer Details": "客戶詳情",
"Current Status": "當前狀態",
"Product Image": "商品圖片",
"PNG, JPG up to 2MB": "支援 PNG, JPG (最大 2MB)"
"PNG, JPG up to 2MB": "支援 PNG, JPG (最大 2MB)",
"menu.data-config.products": "商品管理",
"menu.data-config.advertisements": "廣告管理",
"menu.data-config.admin-products": "商品狀態",
"menu.data-config.points": "點數設定",
"menu.data-config.badges": "徽章設定",
"Create New Role": "建立新角色",
"Create Sub Account Role": "建立子帳號角色",
"Edit Sub Account Role": "編輯子帳號角色",
"New Role": "新角色",
"Manufacturer": "製造商",
"Product status updated to :status": "商品狀態已更新為 :status",
"Customer and associated accounts disabled successfully.": "客戶及其關聯帳號已成功停用。",
"Customer enabled successfully.": "客戶已成功啟用。",
"Cannot delete company with active accounts.": "無法刪除仍有客用帳號的客戶。",
"Customer deleted successfully.": "客戶已成功刪除。"
}

View File

@@ -232,7 +232,7 @@
class="p-2 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 Naz 010 1.971l-11.54 6.347c-.75.412-1.667-.13-1.667-.986V5.653z" />
<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 0 1 0 1.971l-11.54 6.347c-.75.412-1.667-.13-1.667-.986V5.653z" />
</svg>
</button>
@endif

View File

@@ -743,13 +743,15 @@ $roleSelectConfig = [
get filteredRoles() {
const companyId = this.currentUser.company_id;
if (!companyId || companyId.toString().trim() === '') {
// 系統管理層級:僅顯示全域角色 (company_id 為空)
return this.allRoles.filter(r => !r.company_id || r.company_id.toString().trim() === '');
} else {
let companyRoles = this.allRoles.filter(r => r.company_id == companyId);
if (companyRoles.length > 0) {
return companyRoles;
} else {
return this.allRoles.filter(r => !r.company_id || r.company_id.toString().trim() === '');
// 租戶層級 fallback顯示全域角色但明確排除 super-admin
return this.allRoles.filter(r => (!r.company_id || r.company_id.toString().trim() === '') && r.name !== 'super-admin');
}
}
},
@@ -763,7 +765,8 @@ $roleSelectConfig = [
roles = this.allRoles.filter(r => !r.company_id || r.company_id.toString().trim() === '');
} else {
let companyRoles = this.allRoles.filter(r => r.company_id == initialCompanyId);
roles = companyRoles.length > 0 ? companyRoles : this.allRoles.filter(r => !r.company_id || r.company_id.toString().trim() === '');
// 這裡也要同步排除 super-admin
roles = companyRoles.length > 0 ? companyRoles : this.allRoles.filter(r => (!r.company_id || r.company_id.toString().trim() === '') && r.name !== 'super-admin');
}
if (roles.length > 0) {

View File

@@ -122,8 +122,6 @@ $roleSelectConfig = [
@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>
<span class="text-xs font-bold text-slate-400 mx-1">/</span>
<span class="text-sm font-black text-emerald-500">${{ number_format($product->member_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>
@@ -132,19 +130,34 @@ $roleSelectConfig = [
</td>
<td class="px-6 py-6 text-center">
@if($product->is_active)
<span class="px-3 py-1 rounded-full text-[10px] font-black uppercase tracking-widest bg-emerald-500/10 text-emerald-600 border border-emerald-500/20 shadow-sm shadow-emerald-500/10">{{ __('Active') }}</span>
<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="px-3 py-1 rounded-full text-[10px] font-black uppercase tracking-widest bg-slate-500/10 text-slate-500 border border-slate-500/20">{{ __('Disabled') }}</span>
<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">
<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>
@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>
@@ -169,7 +182,21 @@ $roleSelectConfig = [
<!-- Delete Confirm Modal -->
<x-delete-confirm-modal :message="__('Are you sure you want to delete this product? All related historical translation data will also be removed.')" />
<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
@@ -202,139 +229,165 @@ $roleSelectConfig = [
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"
class="relative w-screen max-w-2xl"
class="relative w-screen max-w-md"
@click.stop>
<div class="h-full flex flex-col bg-white dark:bg-slate-900 shadow-2xl border-l border-slate-200 dark:border-white/10 overflow-y-auto luxury-scrollbar">
<!-- Close Button Overlay -->
<div class="absolute top-6 right-6 z-10">
<button @click="isDetailOpen = false" class="p-2 rounded-full bg-slate-100/80 dark:bg-slate-800/80 text-slate-400 hover:text-slate-600 dark:hover:text-slate-200 backdrop-blur-md transition-all hover:rotate-90">
<svg class="size-6" 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>
<div class="px-6 py-3 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">{{ __('Product Details') }}</h2>
<p class="text-[11px] font-bold text-slate-400 uppercase tracking-[0.2em] mt-1" x-text="selectedProduct?.name + ' (' + getCategoryName(selectedProduct?.category_id) + ')'"></p>
</div>
<button @click="isDetailOpen = false"
class="p-2 rounded-xl hover:bg-slate-50 dark:hover:bg-slate-800 transition-colors">
<svg class="w-5 h-5 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div class="p-8 md:p-12">
<!-- Header with Status -->
<div class="flex flex-col md:flex-row gap-10 mb-12 animate-luxury-in">
<!-- Image Section -->
<div class="w-full md:w-48 shrink-0">
<div class="aspect-square rounded-[2rem] bg-slate-50 dark:bg-slate-800 flex items-center justify-center overflow-hidden border border-slate-100 dark:border-white/5 shadow-inner group">
<template x-if="selectedProduct?.image_url">
<img :src="selectedProduct.image_url" class="w-full h-full object-cover group-hover:scale-110 transition-transform duration-700">
</template>
<template x-if="!selectedProduct?.image_url">
<svg class="size-16 text-slate-200 dark:text-slate-700" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.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>
</template>
</div>
</div>
<!-- Identity Section -->
<div class="flex-1 flex flex-col justify-center">
<div class="inline-flex items-center gap-2 mb-4">
<span class="px-3 py-1 rounded-full text-[10px] font-black uppercase tracking-widest border transition-all duration-300"
<div class="flex-1 overflow-y-auto px-6 py-8 space-y-8 custom-scrollbar">
<!-- Header Status Info (Minimized) -->
<div class="flex items-center gap-3 animate-luxury-in">
<span class="px-3 py-1 rounded-full text-xs font-black uppercase tracking-widest border transition-all duration-300"
:class="selectedProduct?.is_active ? 'bg-emerald-500/10 text-emerald-600 border-emerald-500/20 shadow-sm shadow-emerald-500/10' : 'bg-slate-500/10 text-slate-500 border-slate-500/20'">
<span x-text="selectedProduct?.is_active ? '{{ __('Active') }}' : '{{ __('Disabled') }}'"></span>
</span>
<span class="text-[10px] font-bold text-slate-400 dark:text-slate-500 uppercase tracking-widest bg-slate-100 dark:bg-slate-800 px-2 py-1 rounded-lg border border-transparent dark:border-white/5" x-text="getCategoryName(selectedProduct?.category_id)"></span>
</div>
<h2 class="text-4xl font-black text-slate-800 dark:text-white leading-tight font-display tracking-tight" x-text="selectedProduct?.name"></h2>
<p class="text-sm font-mono font-bold text-cyan-500 mt-2 tracking-widest uppercase" x-text="selectedProduct?.barcode"></p>
</div>
<span class="text-xs font-bold text-slate-400 dark:text-slate-300" x-text="'ID: #' + (selectedProduct?.id || '-')"></span>
</div>
<!-- Info Grid -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-10 mb-12">
<!-- Pricing Card -->
<div class="luxury-card p-6 rounded-[1.5rem] border border-slate-100 dark:border-white/5 space-y-6">
<h3 class="text-xs font-black text-slate-400 uppercase tracking-[.2em] flex items-center gap-2">
<svg class="size-4 text-cyan-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
{{ __('Pricing Details') }}
</h3>
<div class="grid grid-cols-2 gap-6">
<div class="space-y-1">
<p class="text-[10px] font-bold text-slate-400 uppercase tracking-widest">{{ __('List Price') }}</p>
<p class="text-2xl font-black text-slate-800 dark:text-white leading-none">$<span x-text="formatNumber(selectedProduct?.price)"></span></p>
</div>
<div class="space-y-1">
<p class="text-[10px] font-bold text-slate-400 uppercase tracking-widest">{{ __('Member Price') }}</p>
<p class="text-2xl font-black text-emerald-500 leading-none">$<span x-text="formatNumber(selectedProduct?.member_price)"></span></p>
</div>
<div class="space-y-1 col-span-2 pt-4 border-t border-slate-50 dark:border-white/5">
<p class="text-[10px] font-bold text-slate-400 uppercase tracking-widest">{{ __('Cost Basis') }}</p>
<p class="text-lg font-bold text-slate-500 leading-none">$<span x-text="formatNumber(selectedProduct?.cost)"></span></p>
</div>
</div>
</div>
<!-- Specs Card -->
<div class="luxury-card p-6 rounded-[1.5rem] border border-slate-100 dark:border-white/5 space-y-6">
<h3 class="text-xs font-black text-slate-400 uppercase tracking-[.2em] flex items-center gap-2">
<svg class="size-4 text-indigo-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" 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>
{{ __('Channel Limits') }}
</h3>
<div class="space-y-8">
<!-- Image Section (Square) -->
<template x-if="selectedProduct?.image_url">
<section class="animate-luxury-in">
<h3 class="text-[10px] font-black text-indigo-500 uppercase tracking-[0.3em] mb-4">{{ __('Product Image') }}</h3>
<div @click="isImageZoomed = true"
class="max-w-xs mx-auto aspect-square rounded-[2rem] bg-slate-50 dark:bg-slate-800 overflow-hidden border border-slate-100 dark:border-white/5 shadow-lg group relative cursor-zoom-in">
<img :src="selectedProduct.image_url" class="absolute inset-0 w-full h-full object-cover group-hover:scale-105 transition-transform duration-1000">
<div class="absolute inset-0 bg-slate-950/20 opacity-0 group-hover:opacity-100 transition-opacity duration-300 flex items-center justify-center">
<div class="p-3 rounded-full bg-white/20 backdrop-blur-md text-white border border-white/30 scale-50 group-hover:scale-100 transition-all duration-500 shadow-2xl">
<svg class="size-6 shadow-glow" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607zM10.5 7.5v6m3-3h-6" /></svg>
</div>
</div>
</div>
</section>
</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>
<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">
<span class="text-xs font-bold text-slate-400 uppercase tracking-widest block mb-1.5">{{ __('Barcode') }}</span>
<div class="text-[15px] font-mono font-bold text-slate-700 dark:text-slate-200" x-text="selectedProduct?.barcode || '-'"></div>
</div>
<template x-if="selectedProduct?.metadata?.material_code">
<div class="bg-slate-50 dark:bg-slate-800/40 p-5 rounded-2xl border border-slate-100 dark:border-slate-800/80 group hover:border-cyan-500/30 transition-colors">
<span class="text-xs font-bold text-slate-400 uppercase tracking-widest block mb-1.5">{{ __('Material Code') }}</span>
<div class="text-[15px] font-mono font-bold text-slate-700 dark:text-slate-200" x-text="selectedProduct?.metadata?.material_code"></div>
</div>
</template>
<div class="bg-slate-50 dark:bg-slate-800/40 p-5 rounded-2xl border border-slate-100 dark:border-slate-800/80 group hover:border-cyan-500/30 transition-colors">
<span class="text-xs font-bold text-slate-400 uppercase tracking-widest block mb-1.5">{{ __('Manufacturer') }}</span>
<div class="text-[15px] font-bold text-slate-700 dark:text-slate-200" x-text="selectedProduct?.manufacturer || '-'"></div>
</div>
<template x-if="selectedProduct?.company?.name">
<div class="bg-slate-50 dark:bg-slate-800/40 p-5 rounded-2xl border border-slate-100 dark:border-slate-800/80 group hover:border-cyan-500/30 transition-colors">
<span class="text-xs font-bold text-slate-400 uppercase tracking-widest block mb-1.5">{{ __('Company') }}</span>
<div class="text-[15px] font-bold text-slate-700 dark:text-slate-200" x-text="selectedProduct?.company?.name"></div>
</div>
</template>
</div>
</section>
<!-- Pricing Section -->
<section class="space-y-4 animate-luxury-in" style="animation-delay: 200ms">
<h3 class="text-xs font-black text-emerald-500 uppercase tracking-[0.3em]">{{ __('Pricing Information') }}</h3>
<div class="luxury-card divide-y divide-slate-50 dark:divide-white/5 overflow-hidden border border-slate-100 dark:border-white/5 shadow-sm">
<div class="p-5 flex items-center justify-between group hover:bg-slate-50/50 dark:hover:bg-white/5 transition-colors">
<span class="text-[15px] font-bold text-slate-500">{{ __('Retail Price') }}</span>
<span class="text-lg font-black text-slate-800 dark:text-white">$<span x-text="formatNumber(selectedProduct?.price)"></span></span>
</div>
<div class="p-5 flex items-center justify-between group hover:bg-slate-50/50 dark:hover:bg-white/5 transition-colors">
<span class="text-[15px] font-bold text-slate-500">{{ __('Member Price') }}</span>
<span class="text-lg font-black text-emerald-500">$<span x-text="formatNumber(selectedProduct?.member_price)"></span></span>
</div>
<div class="p-5 flex items-center justify-between group hover:bg-slate-50/50 dark:hover:bg-white/5 transition-colors">
<span class="text-[15px] font-bold text-slate-400">{{ __('Cost') }}</span>
<span class="text-sm font-bold text-slate-500 tracking-tight">$<span x-text="formatNumber(selectedProduct?.cost)"></span></span>
</div>
</div>
</section>
<!-- 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>
<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">
<div class="space-y-1">
<p class="text-[10px] font-bold text-slate-400 uppercase tracking-widest">{{ __('Track Limit') }}</p>
<p class="text-3xl font-black text-indigo-500 tracking-tighter" x-text="selectedProduct?.track_limit"></p>
<p class="text-xs font-bold text-slate-400 uppercase tracking-widest">{{ __('Track Limit') }}</p>
<p class="text-3xl font-black text-indigo-500 tracking-tighter" x-text="selectedProduct?.track_limit || '0'"></p>
</div>
<div class="h-10 w-px bg-slate-100 dark:bg-white/10"></div>
<div class="space-y-1 text-right">
<p class="text-[10px] font-bold text-slate-400 uppercase tracking-widest">{{ __('Spring Limit') }}</p>
<p class="text-3xl font-black text-amber-500 tracking-tighter" x-text="selectedProduct?.spring_limit"></p>
<p class="text-xs font-bold text-slate-400 uppercase tracking-widest">{{ __('Spring Limit') }}</p>
<p class="text-3xl font-black text-amber-500 tracking-tighter" x-text="selectedProduct?.spring_limit || '0'"></p>
</div>
</div>
<div class="relative h-2 bg-slate-100 dark:bg-slate-800 rounded-full overflow-hidden">
<div class="absolute inset-y-0 left-0 bg-indigo-500 rounded-full transition-all duration-1000" :style="`width: ${Math.min(100, (selectedProduct?.track_limit / 50) * 100)}%`" x-show="isDetailOpen"></div>
</div>
</section>
<!-- 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>
<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">
<span class="text-xs font-black text-slate-400 uppercase tracking-widest block mb-1">{{ __('Full Points') }}</span>
<div class="text-lg font-black text-rose-500 font-mono" x-text="selectedProduct?.metadata?.points_full || '0'"></div>
</div>
<div class="bg-slate-50 dark:bg-slate-800/40 p-5 rounded-2xl border border-slate-100 dark:border-slate-800/80">
<span class="text-xs font-black text-slate-400 uppercase tracking-widest block mb-1">{{ __('Half Points') }}</span>
<div class="text-lg font-black text-indigo-500 font-mono" x-text="selectedProduct?.metadata?.points_half || '0'"></div>
</div>
<div class="bg-slate-50 dark:bg-slate-800/40 p-5 rounded-2xl border border-slate-100 dark:border-slate-800/80">
<span class="text-xs font-black text-slate-400 uppercase tracking-widest block mb-1">{{ __('Half Points Amount') }}</span>
<div class="text-lg font-black text-emerald-500 font-mono" x-text="selectedProduct?.metadata?.points_half_amount || '0'"></div>
</div>
</div>
</section>
</div>
</div>
<div class="p-6 border-t border-slate-100 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-900/50">
<button @click="isDetailOpen = false" class="w-full btn-luxury-ghost">{{ __('Close Panel') }}</button>
</div>
</div>
</div>
</div>
<!-- Extended Features Section -->
<div class="luxury-card p-8 rounded-[2rem] bg-slate-50/50 dark:bg-slate-800/30 border border-slate-100 dark:border-white/5 mb-12">
<h3 class="text-xs font-black text-slate-800 dark:text-white uppercase tracking-[0.25em] mb-8 flex items-center gap-3">
<span class="size-2 rounded-full bg-cyan-500 animate-pulse"></span>
{{ __('Feature Configurations') }}
</h3>
<div class="grid grid-cols-2 md:grid-cols-4 gap-8">
<div class="space-y-1.5 p-4 rounded-2xl bg-white dark:bg-slate-900 border border-slate-50 dark:border-white/5">
<p class="text-[10px] font-bold text-slate-400 uppercase tracking-widest">{{ __('Material Code') }}</p>
<p class="text-sm font-black text-slate-700 dark:text-slate-200 font-mono" x-text="selectedProduct?.metadata?.material_code || '-'"></p>
</div>
<div class="space-y-1.5 p-4 rounded-2xl bg-white dark:bg-slate-900 border border-slate-50 dark:border-white/5">
<p class="text-[10px] font-bold text-slate-400 uppercase tracking-widest">{{ __('Full Points') }}</p>
<p class="text-sm font-black text-cyan-600 dark:text-cyan-400 font-mono" x-text="selectedProduct?.metadata?.points_full || '0'"></p>
</div>
<div class="space-y-1.5 p-4 rounded-2xl bg-white dark:bg-slate-900 border border-slate-50 dark:border-white/5">
<p class="text-[10px] font-bold text-slate-400 uppercase tracking-widest">{{ __('Half Points') }}</p>
<p class="text-sm font-black text-indigo-600 dark:text-indigo-400 font-mono" x-text="selectedProduct?.metadata?.points_half || '0'"></p>
</div>
<div class="space-y-1.5 p-4 rounded-2xl bg-white dark:bg-slate-900 border border-slate-50 dark:border-white/5">
<p class="text-[10px] font-bold text-slate-400 uppercase tracking-widest">{{ __('Half Amount') }}</p>
<p class="text-sm font-black text-emerald-600 dark:text-emerald-400 font-mono" x-text="selectedProduct?.metadata?.points_half_amount || '0'"></p>
</div>
</div>
</div>
<!-- Image Zoom Modal -->
<div x-show="isImageZoomed"
x-transition:enter="ease-out duration-300"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
x-transition:leave="ease-in duration-200"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
class="fixed inset-0 z-[110] flex items-center justify-center p-4 bg-slate-950/90 backdrop-blur-xl"
@keydown.escape.window="isImageZoomed = false"
x-cloak>
<!-- Footer Timestamps -->
<div class="flex flex-col md:flex-row items-center justify-between gap-4 pt-10 border-t border-slate-100 dark:border-white/5 text-[10px] font-bold text-slate-400 uppercase tracking-[.25em]">
<div class="flex items-center gap-6">
<div class="flex flex-col gap-1">
<span>{{ __('Created At') }}</span>
<span class="text-slate-500 dark:text-slate-300 font-mono" x-text="formatDate(selectedProduct?.created_at)"></span>
</div>
<div class="w-px h-8 bg-slate-100 dark:bg-white/10 hidden md:block"></div>
<div class="flex flex-col gap-1">
<span>{{ __('Updated At') }}</span>
<span class="text-slate-500 dark:text-slate-300 font-mono" x-text="formatDate(selectedProduct?.updated_at)"></span>
</div>
</div>
<button @click="isDetailOpen = false" class="btn-luxury-secondary px-8 py-3">
{{ __('Close Panel') }}
<button @click="isImageZoomed = false" class="absolute top-6 right-6 p-3 rounded-full bg-white/10 text-white hover:bg-white/20 transition-all border border-white/10 active:scale-95">
<svg class="size-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" /></svg>
</button>
</div>
</div>
<div class="relative max-w-5xl w-full aspect-square md:aspect-auto md:max-h-[90vh] flex items-center justify-center" @click.away="isImageZoomed = false">
<img :src="selectedProduct?.image_url" class="max-w-full max-h-full rounded-[2.5rem] shadow-2xl border border-white/10 animate-luxury-in">
<div class="absolute bottom-[-4rem] left-1/2 -translate-x-1/2 text-white/60 text-sm font-bold tracking-widest uppercase animate-luxury-in" style="animation-delay: 200ms">
<span x-text="selectedProduct?.name"></span>
</div>
</div>
</div>
@@ -348,10 +401,17 @@ $roleSelectConfig = [
Alpine.data('productManager', () => ({
isDeleteConfirmOpen: false,
isDetailOpen: false,
isImageZoomed: false,
isStatusConfirmOpen: false,
deleteFormAction: '',
toggleFormAction: '',
selectedProduct: null,
categories: [],
submitConfirmedForm() {
this.$refs.statusToggleForm.submit();
},
init() {
this.categories = JSON.parse(this.$el.dataset.categories || '[]');
},
@@ -368,7 +428,7 @@ $roleSelectConfig = [
getCategoryName(id) {
const category = this.categories.find(c => c.id == id);
return category ? (category.name || '{{ __('Uncategorized') }}') : '{{ __('Uncategorized') }}';
return category ? (category.name || "{{ __('Uncategorized') }}") : "{{ __('Uncategorized') }}";
},
formatNumber(val) {

View File

@@ -65,6 +65,12 @@
'companies' => __('Customer Management'),
'members' => __('Member List'),
'machines' => __('Machine Settings'),
'products' => __('Product Management'),
'machine-models' => __('Machine Model Settings'),
'accounts' => __('Account Management'),
'sub-accounts' => __('Account Management'),
'roles' => __('Roles'),
'sub-account-roles' => __('Sub Account Roles'),
'payment-configs' => __('Customer Payment Config'),
'warehouses' => __('Warehouse List'),
'sales' => __('Sales Records'),
@@ -78,6 +84,10 @@
'url' => match($midSegment) {
'maintenance' => route('admin.maintenance.index'),
'machines' => str_contains($routeName, 'basic-settings') ? route('admin.basic-settings.machines.index') : '#',
'products' => route('admin.data-config.products.index'),
'accounts' => route('admin.permission.accounts'),
'sub-accounts' => route('admin.data-config.sub-accounts'),
'sub-account-roles' => route('admin.data-config.sub-account-roles'),
default => '#',
},
'active' => $lastSegment === 'index'
@@ -139,7 +149,6 @@
'survey-analysis' => __('Survey Analysis'),
'products' => __('Product Management'),
'advertisements' => __('Advertisement Management'),
'admin-products' => __('Admin Sellable Products'),
'accounts' => __('Account Management'),
'sub-accounts' => __('Sub Accounts'),
'sub-account-roles' => __('Sub Account Roles'),

View File

@@ -200,17 +200,48 @@
</button>
<div x-show="open" x-collapse>
<ul class="luxury-submenu" data-sidebar-sub>
<li><a class="flex items-center gap-x-3.5 py-2 px-2.5 text-sm transition-colors rounded-lg {{ request()->routeIs('admin.data-config.products.*') ? 'text-slate-900 dark:text-white bg-slate-100 dark:bg-white/5' : 'text-slate-500 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white' }}" href="{{ route('admin.data-config.products.index') }}">{{ __('Product Management') }}</a></li>
<li><a class="flex items-center gap-x-3.5 py-2 px-2.5 text-sm transition-colors rounded-lg {{ request()->routeIs('admin.data-config.advertisements') ? 'text-slate-900 dark:text-white bg-slate-100 dark:bg-white/5' : 'text-slate-500 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white' }}" href="{{ route('admin.data-config.advertisements') }}">{{ __('Advertisement Management') }}</a></li>
<li><a class="flex items-center gap-x-3.5 py-2 px-2.5 text-sm transition-colors rounded-lg {{ request()->routeIs('admin.data-config.admin-products') ? 'text-slate-900 dark:text-white bg-slate-100 dark:bg-white/5' : 'text-slate-500 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white' }}" href="{{ route('admin.data-config.admin-products') }}">{{ __('Product Status') }}</a></li>
@can('menu.data-config.products')
<li><a class="flex items-center gap-x-3.5 py-2 px-2.5 text-sm transition-colors rounded-lg {{ request()->routeIs('admin.data-config.products.*') ? 'text-slate-900 dark:text-white bg-slate-100 dark:bg-white/5' : 'text-slate-500 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white' }}" href="{{ route('admin.data-config.products.index') }}">
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4 shrink-0 transition-colors" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M16 11V7a4 4 0 00-8 0v4M5 9h14l1 12H4L5 9z" /></svg>
{{ __('Product Management') }}
</a></li>
@endcan
@can('menu.data-config.advertisements')
<li><a class="flex items-center gap-x-3.5 py-2 px-2.5 text-sm transition-colors rounded-lg {{ request()->routeIs('admin.data-config.advertisements') ? 'text-slate-900 dark:text-white bg-slate-100 dark:bg-white/5' : 'text-slate-500 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white' }}" href="{{ route('admin.data-config.advertisements') }}">
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4 shrink-0 transition-colors" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M11 5.882V19.24a1.76 1.76 0 01-3.417.592l-2.147-6.15M18 13a3 3 0 100-6M5.436 13.683A4.001 4.001 0 017 6h1.832c4.1 0 7.625-1.234 9.168-3v14c-1.543-1.766-5.067-3-9.168-3H7a3.988 3.988 0 01-1.564-.317z" /></svg>
{{ __('Advertisement Management') }}
</a></li>
@endcan
@can('menu.data-config.sub-accounts')
<li><a class="flex items-center gap-x-3.5 py-2 px-2.5 text-sm transition-colors rounded-lg {{ request()->routeIs('admin.data-config.sub-accounts') ? 'text-slate-900 dark:text-white bg-slate-100 dark:bg-white/5' : 'text-slate-500 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white' }}" href="{{ route('admin.data-config.sub-accounts') }}">{{ __('Sub Accounts') }}</a></li>
<li><a class="flex items-center gap-x-3.5 py-2 px-2.5 text-sm transition-colors rounded-lg {{ request()->routeIs('admin.data-config.sub-accounts') ? 'text-slate-900 dark:text-white bg-slate-100 dark:bg-white/5' : 'text-slate-500 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white' }}" href="{{ route('admin.data-config.sub-accounts') }}">
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4 shrink-0 transition-colors" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" /></svg>
{{ __('Sub Accounts') }}
</a></li>
@endcan
@can('menu.data-config.sub-account-roles')
<li><a class="flex items-center gap-x-3.5 py-2 px-2.5 text-sm transition-colors rounded-lg {{ request()->routeIs('admin.data-config.sub-account-roles') ? 'text-slate-900 dark:text-white bg-slate-100 dark:bg-white/5' : 'text-slate-500 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white' }}" href="{{ route('admin.data-config.sub-account-roles') }}">{{ __('Sub Account Roles') }}</a></li>
<li><a class="flex items-center gap-x-3.5 py-2 px-2.5 text-sm transition-colors rounded-lg {{ request()->routeIs('admin.data-config.sub-account-roles') ? 'text-slate-900 dark:text-white bg-slate-100 dark:bg-white/5' : 'text-slate-500 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white' }}" href="{{ route('admin.data-config.sub-account-roles') }}">
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4 shrink-0 transition-colors" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" /></svg>
{{ __('Sub Account Roles') }}
</a></li>
@endcan
@can('menu.data-config.points')
<li><a class="flex items-center gap-x-3.5 py-2 px-2.5 text-sm transition-colors rounded-lg {{ request()->routeIs('admin.data-config.points') ? 'text-slate-900 dark:text-white bg-slate-100 dark:bg-white/5' : 'text-slate-500 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white' }}" href="{{ route('admin.data-config.points') }}">
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4 shrink-0 transition-colors" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
{{ __('Point Settings') }}
</a></li>
@endcan
@can('menu.data-config.badges')
<li><a class="flex items-center gap-x-3.5 py-2 px-2.5 text-sm transition-colors rounded-lg {{ request()->routeIs('admin.data-config.badges') ? 'text-slate-900 dark:text-white bg-slate-100 dark:bg-white/5' : 'text-slate-500 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white' }}" href="{{ route('admin.data-config.badges') }}">
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4 shrink-0 transition-colors" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M12 8v13m0-13V6a2 2 0 112 2h-2zm0 0V5.5A2.5 2.5 0 109.5 8H12zm-7 4h14M5 12a2 2 0 110-4h14a2 2 0 110 4M5 12v7a2 2 0 002 2h10a2 2 0 002-2v-7" /></svg>
{{ __('Badge Settings') }}
</a></li>
@endcan
<li><a class="flex items-center gap-x-3.5 py-2 px-2.5 text-sm transition-colors rounded-lg {{ request()->routeIs('admin.data-config.points') ? 'text-slate-900 dark:text-white bg-slate-100 dark:bg-white/5' : 'text-slate-500 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white' }}" href="{{ route('admin.data-config.points') }}">{{ __('Point Settings') }}</a></li>
<li><a class="flex items-center gap-x-3.5 py-2 px-2.5 text-sm transition-colors rounded-lg {{ request()->routeIs('admin.data-config.badges') ? 'text-slate-900 dark:text-white bg-slate-100 dark:bg-white/5' : 'text-slate-500 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white' }}" href="{{ route('admin.data-config.badges') }}">{{ __('Badge Settings') }}</a></li>
</ul>
</div>
</li>

View File

@@ -114,8 +114,8 @@ Route::middleware(['auth', 'verified', 'tenant.access'])->prefix('admin')->name(
// 9. 資料設定
Route::prefix('data-config')->name('data-config.')->group(function () {
Route::resource('products', App\Http\Controllers\Admin\ProductController::class)->except(['show']);
Route::patch('/products/{id}/toggle-status', [App\Http\Controllers\Admin\ProductController::class, 'toggleStatus'])->name('products.status.toggle');
Route::get('/advertisements', [App\Http\Controllers\Admin\DataConfigController::class , 'advertisements'])->name('advertisements');
Route::get('/admin-products', [App\Http\Controllers\Admin\DataConfigController::class , 'adminProducts'])->name('admin-products');
Route::get('/sub-accounts', [App\Http\Controllers\Admin\PermissionController::class , 'accounts'])->name('sub-accounts')->middleware('can:menu.data-config.sub-accounts');
Route::patch('/sub-accounts/{id}/toggle-status', [App\Http\Controllers\Admin\PermissionController::class, 'toggleAccountStatus'])->name('sub-accounts.status.toggle')->middleware('can:menu.data-config.sub-accounts');
Route::post('/sub-accounts', [App\Http\Controllers\Admin\PermissionController::class , 'storeAccount'])->name('sub-accounts.store')->middleware('can:menu.data-config.sub-accounts');

View File

@@ -0,0 +1,101 @@
<?php
namespace Tests\Feature\Admin;
use App\Models\System\Company;
use Spatie\Permission\Models\Permission;
use App\Models\System\Role;
use App\Models\System\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class AccountRoleFilterTest extends TestCase
{
use RefreshDatabase;
protected $admin;
protected $company;
protected $templateRole;
protected $superAdminRole;
protected function setUp(): void
{
parent::setUp();
// 建立測試權限
Permission::create(['name' => 'menu.permissions.accounts', 'guard_name' => 'web']);
// 建立測試角色
$this->superAdminRole = Role::create([
'name' => 'super-admin',
'company_id' => null,
'is_system' => true,
'guard_name' => 'web'
]);
$this->superAdminRole->givePermissionTo('menu.permissions.accounts');
$this->templateRole = Role::create([
'name' => '客戶管理員角色模板',
'company_id' => null,
'is_system' => true,
'guard_name' => 'web'
]);
$this->company = Company::create([
'name' => 'Test Company',
'code' => 'TEST'
]);
// 建立系統管理員
$this->admin = User::factory()->create(['company_id' => null]);
$this->admin->assignRole($this->superAdminRole);
}
/**
* 測試租戶帳號不能被指派 super-admin 角色
*/
public function test_super_admin_cannot_be_assigned_to_tenant_account()
{
$this->withoutExceptionHandling();
$this->actingAs($this->admin);
$response = $this->post(route('admin.permission.accounts.store'), [
'name' => 'Tenant User',
'username' => 'tenantuser',
'email' => 'tenant@example.com',
'password' => 'password123',
'role' => 'super-admin',
'status' => 1,
'company_id' => $this->company->id,
]);
$response->assertSessionHas('error');
$this->assertDatabaseMissing('users', ['username' => 'tenantuser']);
}
/**
* 測試角色範本可以被指派給租戶,並轉換為「管理員」
*/
public function test_template_role_can_be_assigned_and_cloned_for_tenant()
{
$this->actingAs($this->admin);
$response = $this->post(route('admin.permission.accounts.store'), [
'name' => 'Tenant User',
'username' => 'tenantuser',
'email' => 'tenant@example.com',
'password' => 'password123',
'role' => '客戶管理員角色模板',
'status' => 1,
'company_id' => $this->company->id,
]);
$response->assertSessionHas('success');
$this->assertDatabaseHas('users', ['username' => 'tenantuser']);
$user = User::where('username', 'tenantuser')->first();
// 根據控制器邏輯,非 super-admin 的全域角色會被克隆為該公司的「管理員」
$this->assertTrue($user->hasRole('管理員'));
$this->assertEquals($this->company->id, $user->company_id);
}
}