[FIX] 修正角色權限顯示邏輯與 UI 組件重構

1. 修正角色編輯頁面、列表頁面與卡片視圖中的權限數量計算邏輯,確保過濾掉無效或隱藏權限。
2. 重構角色權限顯示 UI,將彈窗由不穩定的 Popover 改為基於 Alpine.js x-teleport 的全螢幕置中模窗,解決容器遮擋問題。
3. 抽離並組件化角色清單 (role-list) 與卡片 (role-card) 視圖,提升程式碼複用性。
4. 統一子帳號管理頁面的搜尋與重設按鈕佈局,確保全站視覺一致性。
5. 更新多語系翻譯,修正『租戶』為『客戶』,並新增『全部權限』翻譯項。
6. 完成取貨碼與密碼管理模組的極簡奢華風 UI 遷移。
This commit is contained in:
sky121113 2026-04-28 16:13:57 +08:00
parent d00444840f
commit 0332eff9bc
17 changed files with 1705 additions and 1317 deletions

View File

@ -10,7 +10,7 @@ class PermissionController extends Controller
// 權限角色設定 // 權限角色設定
public function roles() public function roles()
{ {
$per_page = request()->input('per_page', 10); $per_page = request()->input('roles_per_page', request()->input('per_page', 10));
$user = auth()->user(); $user = auth()->user();
$query = \App\Models\System\Role::query()->with(['permissions', 'users', 'company']); $query = \App\Models\System\Role::query()->with(['permissions', 'users', 'company']);
@ -19,21 +19,23 @@ class PermissionController extends Controller
$query->where('company_id', $user->company_id); $query->where('company_id', $user->company_id);
} }
// 搜尋:角色名稱 // 搜尋:角色名稱(支援 roles_search 命名空間 與舊版 search
if ($search = request()->input('search')) { $search = request()->input('roles_search', request()->input('search'));
if ($search) {
$query->where('name', 'like', "%{$search}%"); $query->where('name', 'like', "%{$search}%");
} }
// 篩選:公司名稱 (僅限系統管理員) // 篩選:公司名稱(支援 roles_company_id 命名空間 與舊版 company_id
if ($user->isSystemAdmin() && request()->filled('company_id')) { $company_id = request()->input('roles_company_id', request()->input('company_id'));
if (request()->company_id === 'system') { if ($user->isSystemAdmin() && filled($company_id)) {
if ($company_id === 'system') {
$query->whereNull('company_id'); $query->whereNull('company_id');
} else { } else {
$query->where('company_id', request()->company_id); $query->where('company_id', $company_id);
} }
} }
$roles = $query->latest()->paginate($per_page)->withQueryString(); $roles = $query->latest()->paginate($per_page, ['*'], 'roles_page')->withQueryString();
$companies = $user->isSystemAdmin() ? \App\Models\System\Company::all() : collect(); $companies = $user->isSystemAdmin() ? \App\Models\System\Company::all() : collect();
// 權限分組邏輯中的標題與過濾 // 權限分組邏輯中的標題與過濾
@ -66,13 +68,9 @@ class PermissionController extends Controller
$role = new \App\Models\System\Role(); $role = new \App\Models\System\Role();
$user = auth()->user(); $user = auth()->user();
// 權限遞迴約束 // 權限遞迴約束由 getFilteredPermissions 處理
$permissionQuery = \Spatie\Permission\Models\Permission::query();
if (!$user->isSystemAdmin()) {
$permissionQuery->whereIn('name', $user->getAllPermissions()->pluck('name'));
}
$all_permissions = $permissionQuery->get()->groupBy(fn($p) => str_starts_with($p->name, 'menu.') ? 'menu' : 'other'); $all_permissions = $this->getFilteredPermissions($user);
$title = request()->routeIs('*.sub-account-roles.create') ? __('Create Sub Account Role') : __('Create New Role'); $title = request()->routeIs('*.sub-account-roles.create') ? __('Create Sub Account Role') : __('Create New Role');
$back_url = request()->routeIs('*.sub-account-roles.create') $back_url = request()->routeIs('*.sub-account-roles.create')
@ -90,21 +88,9 @@ class PermissionController extends Controller
$role = \App\Models\System\Role::findOrFail($id); $role = \App\Models\System\Role::findOrFail($id);
$user = auth()->user(); $user = auth()->user();
// 權限遞迴約束:租戶管理員只能看到並指派自己擁有的權限 // 權限遞迴約束與分組邏輯由 getFilteredPermissions 處理
$permissionQuery = \Spatie\Permission\Models\Permission::query();
if (!$user->isSystemAdmin()) {
$permissionQuery->whereIn('name', $user->getAllPermissions()->pluck('name'));
}
// 權限分組邏輯 $all_permissions = $this->getFilteredPermissions($user);
$all_permissions = $permissionQuery->get()
->reject(fn($p) => $p->name === 'menu.data-config.sub-account-roles')
->groupBy(function($perm) {
if (str_starts_with($perm->name, 'menu.')) {
return 'menu';
}
return 'other';
});
// 根據路由決定標題 // 根據路由決定標題
$title = request()->routeIs('*.sub-account-roles.edit') ? __('Edit Sub Account Role') : __('Edit Role Permissions'); $title = request()->routeIs('*.sub-account-roles.edit') ? __('Edit Sub Account Role') : __('Edit Role Permissions');
@ -156,7 +142,7 @@ class PermissionController extends Controller
// 如果不是系統角色,排除主選單的系統權限 // 如果不是系統角色,排除主選單的系統權限
if (!$is_system) { if (!$is_system) {
$perms = array_diff($perms, ['menu.basic-settings', 'menu.permissions']); $perms = array_diff($perms, ['menu.basic', 'menu.permissions']);
} }
$role->syncPermissions($perms); $role->syncPermissions($perms);
} }
@ -218,7 +204,7 @@ class PermissionController extends Controller
// 如果不是系統角色,排除主選單的系統權限 // 如果不是系統角色,排除主選單的系統權限
if (!$is_system) { if (!$is_system) {
$perms = array_diff($perms, ['menu.basic-settings', 'menu.permissions']); $perms = array_diff($perms, ['menu.basic', 'menu.permissions']);
} }
$role->syncPermissions($perms); $role->syncPermissions($perms);
@ -259,85 +245,69 @@ class PermissionController extends Controller
{ {
$user = auth()->user(); $user = auth()->user();
$isSubAccountRoute = $request->routeIs('admin.data-config.sub-accounts'); $isSubAccountRoute = $request->routeIs('admin.data-config.sub-accounts');
$tab = $request->input('tab', 'accounts'); $tab = $request->input('tab', 'accounts'); // 僅用於設定 Alpine activeTab 初始值
// 初始化變數 $companies = $user->isSystemAdmin() ? \App\Models\System\Company::all() : collect();
$users = collect(); $currentUserRoleIds = $user->roles->pluck('id')->toArray();
$roles = collect();
// ── 帳號列表 ──────────────────────────────────────────────────
$usersQuery = \App\Models\System\User::query()->with(['company', 'roles', 'machines']);
if (!$user->isSystemAdmin()) {
$usersQuery->where('company_id', $user->company_id);
}
if ($search = $request->input('accounts_search', $request->input('search'))) {
$usersQuery->where(function ($q) use ($search) {
$q->where('name', 'like', "%{$search}%")
->orWhere('username', 'like', "%{$search}%")
->orWhere('email', 'like', "%{$search}%");
});
}
if ($user->isSystemAdmin() && $request->filled('accounts_company_id')) {
$cid = $request->input('accounts_company_id');
$cid === 'system' ? $usersQuery->whereNull('company_id') : $usersQuery->where('company_id', $cid);
}
$accounts_per_page = $request->input('accounts_per_page', 10);
$users = $usersQuery->latest()->paginate($accounts_per_page, ['*'], 'accounts_page')->withQueryString();
// Modal 用的角色選單(不分頁)
$rolesForSelect = \App\Models\System\Role::query();
if (!$user->isSystemAdmin()) {
$rolesForSelect->forCompany($user->company_id);
}
$roles = $rolesForSelect->get();
// ── 角色列表 (僅 data-config Tab 版需要) ─────────────────────
$paginated_roles = collect(); $paginated_roles = collect();
$all_permissions = collect(); $all_permissions = collect();
$currentUserRoleIds = $user->roles->pluck('id')->toArray();
$companies = $user->isSystemAdmin() ? \App\Models\System\Company::all() : collect();
if ($isSubAccountRoute && $tab === 'roles') {
// 處理角色分頁邏輯 (移植自 roles())
$per_page = $request->input('per_page', 10);
$roles_query = \App\Models\System\Role::query()->with(['permissions', 'users', 'company']);
if ($isSubAccountRoute) {
$rolesQuery = \App\Models\System\Role::query()->with(['permissions', 'users', 'company']);
if (!$user->isSystemAdmin()) { if (!$user->isSystemAdmin()) {
$roles_query->where('company_id', $user->company_id); $rolesQuery->where('company_id', $user->company_id);
} }
if ($search = $request->input('roles_search')) {
if ($search = $request->input('search')) { $rolesQuery->where('name', 'like', "%{$search}%");
$roles_query->where('name', 'like', "%{$search}%");
} }
if ($user->isSystemAdmin() && $request->filled('roles_company_id')) {
if ($user->isSystemAdmin() && $request->filled('company_id')) { $cid = $request->input('roles_company_id');
if ($request->company_id === 'system') { $cid === 'system' ? $rolesQuery->where('is_system', true) : $rolesQuery->where('company_id', $cid);
$roles_query->where('is_system', true);
} else {
$roles_query->where('company_id', $request->company_id);
}
} }
$roles_per_page = $request->input('roles_per_page', 10);
$paginated_roles = $rolesQuery->latest()->paginate($roles_per_page, ['*'], 'roles_page')->withQueryString();
$paginated_roles = $roles_query->latest()->paginate($per_page)->withQueryString();
// 權限分組邏輯
$permissionQuery = \Spatie\Permission\Models\Permission::query(); $permissionQuery = \Spatie\Permission\Models\Permission::query();
if (!$user->isSystemAdmin()) { if (!$user->isSystemAdmin()) {
$permissionQuery->whereIn('name', $user->getAllPermissions()->pluck('name')); $permissionQuery->whereIn('name', $user->getAllPermissions()->pluck('name'));
} }
$all_permissions = $permissionQuery->get() $all_permissions = $permissionQuery->get()
->reject(fn($p) => $p->name === 'menu.data-config.sub-account-roles') ->reject(fn($p) => $p->name === 'menu.data-config.sub-account-roles')
->groupBy(fn($p) => str_starts_with($p->name, 'menu.') ? 'menu' : 'other'); ->groupBy(fn($p) => str_starts_with($p->name, 'menu.') ? 'menu' : 'other');
} else {
// 處理帳號名單邏輯
$query = \App\Models\System\User::query()->with(['company', 'roles', 'machines']);
if (!$user->isSystemAdmin()) {
$query->where('company_id', $user->company_id);
}
if ($search = $request->input('search')) {
$query->where(function($q) use ($search) {
$q->where('name', 'like', "%{$search}%")
->orWhere('username', 'like', "%{$search}%")
->orWhere('email', 'like', "%{$search}%");
});
}
if ($user->isSystemAdmin() && $request->filled('company_id')) {
if ($request->company_id === 'system') {
$query->whereNull('company_id');
} else {
$query->where('company_id', $request->company_id);
}
}
$per_page = $request->input('per_page', 10);
$users = $query->latest()->paginate($per_page)->withQueryString();
$roles_query = \App\Models\System\Role::query();
if (!$user->isSystemAdmin()) {
$roles_query->forCompany($user->company_id);
}
$roles = $roles_query->get();
} }
$title = $isSubAccountRoute ? __('Sub Account Management') : __('Account Management'); $title = $isSubAccountRoute ? __('Sub Account Management') : __('Account Management');
$view = $isSubAccountRoute ? 'admin.data-config.accounts' : 'admin.permission.accounts';
return view('admin.data-config.accounts', compact( return view($view, compact(
'users', 'companies', 'roles', 'paginated_roles', 'all_permissions', 'title', 'tab', 'currentUserRoleIds' 'users', 'companies', 'roles', 'paginated_roles', 'all_permissions', 'title', 'tab', 'currentUserRoleIds'
)); ));
} }
@ -592,4 +562,46 @@ class PermissionController extends Controller
$statusText = $user->status ? __('Enabled') : __('Disabled'); $statusText = $user->status ? __('Enabled') : __('Disabled');
return back()->with('success', __('Account :name status has been changed to :status.', ['name' => $user->name, 'status' => $statusText])); return back()->with('success', __('Account :name status has been changed to :status.', ['name' => $user->name, 'status' => $statusText]));
} }
/**
* 獲取過濾後的活動模組權限清單
*/
private function getFilteredPermissions($user)
{
$permissionQuery = \Spatie\Permission\Models\Permission::query();
if (!$user->isSystemAdmin()) {
$permissionQuery->whereIn('name', $user->getAllPermissions()->pluck('name'));
}
$activeModules = [
'menu.machines',
'menu.warehouses',
'menu.sales',
'menu.data-config',
'menu.remote',
'menu.special-permission',
'menu.basic',
'menu.permissions',
];
return $permissionQuery->get()
->filter(function($p) use ($activeModules) {
if (str_starts_with($p->name, 'menu.')) {
foreach ($activeModules as $module) {
if ($p->name === $module || str_starts_with($p->name, $module . '.')) {
return true;
}
}
return false;
}
return true;
})
->reject(fn($p) => $p->name === 'menu.data-config.sub-account-roles')
->groupBy(function($perm) {
if (str_starts_with($perm->name, 'menu.')) {
return 'menu';
}
return 'other';
});
}
} }

View File

@ -27,7 +27,18 @@ class RoleSeeder extends Seeder
'menu.machines.maintenance', 'menu.machines.maintenance',
'menu.app', 'menu.app',
'menu.warehouses', 'menu.warehouses',
'menu.warehouses.overview',
'menu.warehouses.inventory',
'menu.warehouses.transfers',
'menu.warehouses.replenishments',
'menu.warehouses.machine-inventory',
'menu.sales', 'menu.sales',
'menu.sales.records',
'menu.sales.pickup-codes',
'menu.sales.orders',
'menu.sales.promotions',
'menu.sales.pass-codes',
'menu.sales.store-gifts',
'menu.analysis', 'menu.analysis',
'menu.audit', 'menu.audit',
'menu.data-config', 'menu.data-config',
@ -37,10 +48,15 @@ class RoleSeeder extends Seeder
'menu.data-config.points', 'menu.data-config.points',
'menu.data-config.badges', 'menu.data-config.badges',
'menu.remote', 'menu.remote',
'menu.remote.stock',
'menu.remote.commands',
'menu.line', 'menu.line',
'menu.reservation', 'menu.reservation',
'menu.special-permission', 'menu.special-permission',
'menu.basic-settings', 'menu.special-permission.clear-stock',
'menu.special-permission.apk-versions',
'menu.special-permission.discord-notifications',
'menu.basic',
'menu.basic.machines', 'menu.basic.machines',
'menu.basic.payment-configs', 'menu.basic.payment-configs',
'menu.permissions', 'menu.permissions',
@ -73,7 +89,18 @@ class RoleSeeder extends Seeder
'menu.machines.maintenance', 'menu.machines.maintenance',
'menu.app', 'menu.app',
'menu.warehouses', 'menu.warehouses',
'menu.warehouses.overview',
'menu.warehouses.inventory',
'menu.warehouses.transfers',
'menu.warehouses.replenishments',
'menu.warehouses.machine-inventory',
'menu.sales', 'menu.sales',
'menu.sales.records',
'menu.sales.pickup-codes',
'menu.sales.orders',
'menu.sales.promotions',
'menu.sales.pass-codes',
'menu.sales.store-gifts',
'menu.analysis', 'menu.analysis',
'menu.audit', 'menu.audit',
'menu.data-config', 'menu.data-config',
@ -83,9 +110,14 @@ class RoleSeeder extends Seeder
'menu.data-config.points', 'menu.data-config.points',
'menu.data-config.badges', 'menu.data-config.badges',
'menu.remote', 'menu.remote',
'menu.remote.stock',
'menu.remote.commands',
'menu.line', 'menu.line',
'menu.reservation', 'menu.reservation',
'menu.special-permission', 'menu.special-permission',
'menu.special-permission.clear-stock',
'menu.special-permission.apk-versions',
'menu.special-permission.discord-notifications',
]); ]);
} }
} }

View File

@ -1431,5 +1431,9 @@
"Recalculate": "Recalculate", "Recalculate": "Recalculate",
"Replenish": "Replenish", "Replenish": "Replenish",
"Total Stock Volume": "Total Stock Volume", "Total Stock Volume": "Total Stock Volume",
"Stock Level > 50%": "Stock Level > 50%" "Stock Level > 50%": "Stock Level > 50%",
"Manage your company's sub-accounts and role permissions": "Manage your company's sub-accounts and role permissions",
"Tenant": "Tenant",
"Permission Tags": "Permission Tags",
"All Permissions": "All Permissions"
} }

View File

@ -257,5 +257,8 @@
"Product Name": "商品名", "Product Name": "商品名",
"Replenish": "補充", "Replenish": "補充",
"Total Stock Volume": "総在庫量", "Total Stock Volume": "総在庫量",
"Stock Level > 50%": "在庫 50% 以上" "Stock Level > 50%": "在庫 50% 以上",
"Manage your company's sub-accounts and role permissions": "会社のサブアカウントとロール権限の管理",
"Role Management": "ロール管理",
"Account List": "アカウント一覧"
} }

View File

@ -694,8 +694,8 @@
"Manage": "管理", "Manage": "管理",
"Manage Account Access": "管理帳號存取", "Manage Account Access": "管理帳號存取",
"Manage ad materials and machine playback settings": "管理廣告素材與機台播放設定", "Manage ad materials and machine playback settings": "管理廣告素材與機台播放設定",
"Manage administrative and tenant accounts": "管理系統管理者與戶帳號", "Manage administrative and tenant accounts": "管理系統管理者與戶帳號",
"Manage all tenant accounts and validity": "管理所有戶帳號及其效期", "Manage all tenant accounts and validity": "管理所有戶帳號及其效期",
"Manage Expiry": "進入效期管理", "Manage Expiry": "進入效期管理",
"Manage inventory and monitor expiry dates across all machines": "管理各機台庫存架位與效期監控", "Manage inventory and monitor expiry dates across all machines": "管理各機台庫存架位與效期監控",
"Manage machine access permissions": "管理機台存取權限", "Manage machine access permissions": "管理機台存取權限",
@ -734,14 +734,13 @@
"menu.analysis": "數據分析", "menu.analysis": "數據分析",
"menu.app": "APP 運維", "menu.app": "APP 運維",
"menu.audit": "審核管理", "menu.audit": "審核管理",
"menu.basic": "基本管理", "menu.basic": "基本設定",
"menu.basic-settings": "基本設定",
"menu.basic.machines": "機台設定", "menu.basic.machines": "機台設定",
"menu.basic.payment-configs": "客戶金流設定", "menu.basic.payment-configs": "客戶金流設定",
"menu.data-config": "資料設定", "menu.data-config": "資料設定",
"menu.data-config.admin-products": "商品狀態", "menu.data-config.admin-products": "商品狀態",
"menu.data-config.advertisements": "廣告管理", "menu.data-config.advertisements": "廣告管理",
"menu.data-config.badges": "徽章設定", "menu.data-config.badges": "識別證",
"menu.data-config.points": "點數設定", "menu.data-config.points": "點數設定",
"menu.data-config.products": "商品管理", "menu.data-config.products": "商品管理",
"menu.data-config.sub-accounts": "子帳號管理", "menu.data-config.sub-accounts": "子帳號管理",
@ -758,10 +757,26 @@
"menu.permissions.companies": "客戶管理", "menu.permissions.companies": "客戶管理",
"menu.permissions.roles": "角色權限管理", "menu.permissions.roles": "角色權限管理",
"menu.remote": "遠端指令", "menu.remote": "遠端指令",
"menu.remote.commands": "指令中心",
"menu.remote.stock": "遠端效期與庫存",
"menu.reservation": "預約管理", "menu.reservation": "預約管理",
"menu.sales": "銷售報表", "menu.sales": "銷售管理",
"menu.sales.orders": "訂單管理",
"menu.sales.pass-codes": "通行碼管理",
"menu.sales.pickup-codes": "取貨碼管理",
"menu.sales.promotions": "促銷活動",
"menu.sales.records": "銷售紀錄",
"menu.sales.store-gifts": "門市禮贈",
"menu.special-permission": "特殊權限", "menu.special-permission": "特殊權限",
"menu.special-permission.apk-versions": "APK版本",
"menu.special-permission.clear-stock": "庫存清空",
"menu.special-permission.discord-notifications": "Discord通知",
"menu.warehouses": "倉儲管理", "menu.warehouses": "倉儲管理",
"menu.warehouses.inventory": "庫存管理",
"menu.warehouses.machine-inventory": "機台庫存概覽",
"menu.warehouses.overview": "倉庫概覽",
"menu.warehouses.replenishments": "機台補貨單",
"menu.warehouses.transfers": "調撥單",
"Merchant IDs": "特店代號清單", "Merchant IDs": "特店代號清單",
"Merchant payment gateway settings management": "特約商店支付網關參數管理", "Merchant payment gateway settings management": "特約商店支付網關參數管理",
"Message": "訊息", "Message": "訊息",
@ -1317,7 +1332,7 @@
"success": "成功", "success": "成功",
"Super Admin": "超級管理員", "Super Admin": "超級管理員",
"super-admin": "超級管理員", "super-admin": "超級管理員",
"Super-admin role cannot be assigned to tenant accounts.": "超級管理員角色無法指派給戶帳號。", "Super-admin role cannot be assigned to tenant accounts.": "超級管理員角色無法指派給戶帳號。",
"Superseded": "已取代", "Superseded": "已取代",
"Superseded by new adjustment": "此指令已由後面最新的調整所取代", "Superseded by new adjustment": "此指令已由後面最新的調整所取代",
"Superseded by new command": "此指令已由後面最新的指令所取代", "Superseded by new command": "此指令已由後面最新的指令所取代",
@ -1335,8 +1350,8 @@
"System Reboot": "系統重啟", "System Reboot": "系統重啟",
"System Role": "系統角色", "System Role": "系統角色",
"System role name cannot be modified.": "內建系統角色的名稱無法修改。", "System role name cannot be modified.": "內建系統角色的名稱無法修改。",
"System roles cannot be deleted by tenant administrators.": "戶管理員無法刪除系統角色。", "System roles cannot be deleted by tenant administrators.": "戶管理員無法刪除系統角色。",
"System roles cannot be modified by tenant administrators.": "戶管理員無法修改系統角色。", "System roles cannot be modified by tenant administrators.": "戶管理員無法修改系統角色。",
"System Settings": "系統設定", "System Settings": "系統設定",
"System settings updated successfully.": "系統設定更新成功。", "System settings updated successfully.": "系統設定更新成功。",
"System super admin accounts cannot be deleted.": "系統超級管理員帳號無法刪除。", "System super admin accounts cannot be deleted.": "系統超級管理員帳號無法刪除。",
@ -1527,5 +1542,9 @@
"Work Content": "作業內容", "Work Content": "作業內容",
"Product Name": "商品名稱", "Product Name": "商品名稱",
"Replenish": "補貨", "Replenish": "補貨",
"Total Stock Volume": "總庫存量" "Total Stock Volume": "總庫存量",
"Manage your company's sub-accounts and role permissions": "管理您公司的子帳號與角色權限",
"Tenant": "客戶",
"Permission Tags": "權限標籤",
"All Permissions": "全部權限"
} }

View File

@ -0,0 +1,167 @@
{{--
共用帳號列表 Partial
Props:
$users 分頁結果
$baseRoute 路由前綴
$companies 公司選單
$tabHidden 是否嵌入 hidden[name=tab](舊版相容,預設 false
$searchParam search 欄位名稱(預設 accounts_search
$companyParam company_id 欄位名稱(預設 accounts_company_id
$perPageParam per_page 欄位名稱(預設 accounts_per_page
$containerId ajax container id預設 ajax-accounts-container
--}}
@php
$tabHidden ??= false;
$searchParam ??= 'accounts_search';
$companyParam ??= 'accounts_company_id';
$perPageParam ??= 'accounts_per_page';
$containerId ??= 'ajax-accounts-container';
@endphp
<form action="{{ route($baseRoute) }}" method="GET"
class="flex flex-col md:flex-row md:items-center gap-3 mb-8"
@submit.prevent="fetchTabContent($el.action + '?' + new URLSearchParams(new FormData($el)).toString(), '{{ $containerId }}')">
@if($tabHidden)<input type="hidden" name="tab" value="accounts">@endif
<div class="relative group flex-1 md:flex-none">
<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"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
</span>
<input type="text" name="{{ $searchParam }}" value="{{ request($searchParam) }}"
class="py-2.5 pl-12 pr-6 block w-full md:w-80 luxury-input"
placeholder="{{ __('Search users...') }}">
</div>
@if(auth()->user()->isSystemAdmin())
<div class="relative w-full md:w-72">
<x-searchable-select name="{{ $companyParam }}" :options="$companies" :selected="request($companyParam)"
:placeholder="__('All Affiliations')"
onchange="this.form.dispatchEvent(new Event('submit'))" />
</div>
@endif
<input type="hidden" name="{{ $perPageParam }}" value="{{ request($perPageParam, 10) }}">
<div class="flex items-center gap-2 ml-auto md:ml-0 shrink-0">
<button type="submit" class="p-2.5 rounded-xl bg-cyan-500 text-white hover:bg-cyan-600 shadow-lg shadow-cyan-500/25 transition-all" title="{{ __('Search') }}">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/></svg>
</button>
<button type="button"
@click="$el.closest('form').querySelectorAll('input[type=text]').forEach(i=>i.value=''); $el.closest('form').querySelectorAll('select').forEach(s=>{s.value=' ';window.HSSelect?.getInstance(s)?.setValue(' ');}); $el.closest('form').dispatchEvent(new Event('submit'));"
class="p-2.5 rounded-xl bg-slate-100 dark:bg-slate-800 text-slate-500 hover:bg-slate-200 dark:hover:bg-slate-700 transition-all" title="{{ __('Reset') }}">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/></svg>
</button>
</div>
</form>
{{-- 桌面表格 --}}
<div class="hidden xl:block overflow-x-auto">
<table class="w-full text-left border-separate border-spacing-y-0">
<thead>
<tr class="bg-slate-50/50 dark:bg-slate-900/10">
<th class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800">{{ __('User 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">{{ __('Contact Info') }}</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">{{ __('Affiliation') }}</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">{{ __('Role') }}</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($users as $user)
<tr class="group hover:bg-slate-50/50 dark:hover:bg-white/[0.02] transition-colors duration-200">
<td class="px-6 py-6">
<div class="flex items-center gap-4">
<div class="w-10 h-10 rounded-2xl 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 transition-all duration-300 overflow-hidden shadow-sm shrink-0">
@if($user->avatar)<img src="{{ Storage::url($user->avatar) }}" class="w-full h-full object-cover">
@else<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="M15.75 6a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0ZM4.501 20.118a7.5 7.5 0 0 1 14.998 0A17.933 17.933 0 0 1 12 21.75c-2.676 0-5.216-.584-7.499-1.632Z"/></svg>
@endif
</div>
<div class="min-w-0">
<p class="text-base font-extrabold text-slate-800 dark:text-slate-100 group-hover:text-cyan-600 dark:group-hover:text-cyan-400 transition-colors tracking-tight truncate">{{ $user->name }}</p>
<p class="text-xs font-mono font-bold text-slate-400 dark:text-slate-500 uppercase tracking-widest">{{ $user->username }}</p>
</div>
</div>
</td>
<td class="px-6 py-6">
<div class="flex flex-col gap-0.5">
@if($user->phone)<p class="text-xs font-bold text-slate-500 dark:text-slate-400 tracking-widest">{{ $user->phone }}</p>@endif
@if($user->email)<p class="text-xs font-bold text-slate-400 dark:text-slate-500 tracking-wide">{{ $user->email }}</p>@endif
@if(!$user->phone && !$user->email)<span class="text-xs text-slate-300 dark:text-slate-600"></span>@endif
</div>
</td>
@if(auth()->user()->isSystemAdmin())
<td class="px-6 py-6">
@if($user->company)
<span class="text-xs font-bold text-slate-500 dark:text-slate-400 tracking-widest uppercase">{{ $user->company->name }}</span>
@else
<span class="px-2.5 py-1 rounded-full text-[10px] font-black bg-cyan-500/10 text-cyan-600 dark:text-cyan-400 uppercase tracking-widest border border-cyan-500/20">{{ __('SYSTEM') }}</span>
@endif
</td>
@endif
<td class="px-6 py-6 text-center">
<div class="flex flex-wrap justify-center gap-1">
@forelse($user->roles as $role)
<span class="px-2.5 py-1 rounded-full text-[10px] font-black bg-slate-100 dark:bg-slate-800 text-slate-600 dark:text-slate-400 border border-slate-200 dark:border-slate-700 uppercase tracking-widest">{{ $role->name }}</span>
@empty
<span class="text-xs font-bold text-slate-300 dark:text-slate-600">{{ __('No Role') }}</span>
@endforelse
</div>
</td>
<td class="px-6 py-6 text-center">
<x-status-badge :status="$user->status ? 'active' : 'disabled'" size="sm" />
</td>
<td class="px-6 py-6 text-right">
<div class="flex items-center justify-end gap-2">
@if(!$user->hasRole('super-admin') || auth()->user()->hasRole('super-admin'))
@if($user->status)
<button type="button"
@click="toggleFormAction = '{{ route($baseRoute . '.status.toggle', $user->id) }}'; statusToggleSource = 'list'; isStatusConfirmOpen = true"
class="p-2 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 tooltip" title="{{ __('Disable') }}">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="M15.75 5.25v13.5m-7.5-13.5v13.5"/></svg>
</button>
@else
<button type="button"
@click="toggleFormAction = '{{ route($baseRoute . '.status.toggle', $user->id) }}'; $nextTick(() => $refs.statusToggleForm.submit())"
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 tooltip" title="{{ __('Enable') }}">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="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
<button @click="openEditModal(@js($user))"
class="p-2 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 tooltip" title="{{ __('Edit') }}">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="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($baseRoute . '.destroy', $user->id) }}')"
class="p-2 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 tooltip" title="{{ __('Delete') }}">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="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 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0"/></svg>
</button>
@else
<span class="text-[10px] font-black text-slate-300 dark:text-slate-600 uppercase tracking-[0.15em] px-2">{{ __('Protected') }}</span>
@endif
</div>
</td>
</tr>
@empty
<x-empty-state mode="table" :colspan="auth()->user()->isSystemAdmin() ? 6 : 5" :message="__('No users found.')" />
@endforelse
</tbody>
</table>
</div>
{{-- Mobile/Tablet Card Grid --}}
<div class="xl:hidden grid grid-cols-1 md:grid-cols-2 gap-6">
@forelse($users as $user)
@include('admin.data-config.partials.account-card', ['user' => $user, 'baseRoute' => $baseRoute])
@empty
<x-empty-state :message="__('No users found.')" />
@endforelse
</div>
{{-- 分頁 --}}
<div class="mt-8 border-t border-slate-100/50 dark:border-slate-800/50 pt-6">
{{ $users->appends(request()->query())->links('vendor.pagination.luxury', [
'ajax_navigate_event' => 'ajax:navigate:accounts',
'per_page_param' => $perPageParam,
]) }}
</div>

View File

@ -0,0 +1,214 @@
{{--
共用角色列表 Partial
Props:
$roles 分頁結果
$baseRoute 路由前綴(用於 CRUD 操作連結)
$listRoute 搜尋表單 action 路由(可選,預設同 $baseRoute
$companies 公司選單
$tabHidden 是否嵌入 hidden[name=tab](舊版相容,預設 false
$searchParam search 欄位名稱(預設 roles_search
$companyParam company_id 欄位名稱(預設 roles_company_id
$perPageParam per_page 欄位名稱(預設 roles_per_page
$containerId ajax container id預設 ajax-roles-container
--}}
@php
$tabHidden ??= false;
$searchParam ??= 'roles_search';
$companyParam ??= 'roles_company_id';
$perPageParam ??= 'roles_per_page';
$containerId ??= 'ajax-roles-container';
$formAction = isset($listRoute) ? route($listRoute) : route($baseRoute);
@endphp
<form action="{{ $formAction }}" method="GET"
class="flex flex-col md:flex-row md:items-center gap-3 mb-8"
@submit.prevent="fetchTabContent($el.action + '?' + new URLSearchParams(new FormData($el)).toString(), '{{ $containerId }}')">
@if($tabHidden)<input type="hidden" name="tab" value="roles">@endif
<div class="relative group flex-1 md:flex-none">
<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"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
</span>
<input type="text" name="{{ $searchParam }}" value="{{ request($searchParam) }}"
class="py-2.5 pl-12 pr-6 block w-full md:w-80 luxury-input"
placeholder="{{ __('Search roles...') }}">
</div>
@if(auth()->user()->isSystemAdmin())
<div class="relative w-full md:w-72">
<x-searchable-select name="{{ $companyParam }}" :options="$companies" :selected="request($companyParam)"
:placeholder="__('All Affiliations')"
onchange="this.form.dispatchEvent(new Event('submit'))">
<option value="system" {{ request($companyParam) === 'system' ? 'selected' : '' }} data-title="{{ __('System Level') }}">{{ __('System Level') }}</option>
</x-searchable-select>
</div>
@endif
<input type="hidden" name="{{ $perPageParam }}" value="{{ request($perPageParam, 10) }}">
<div class="flex items-center gap-2 ml-auto md:ml-0 shrink-0">
<button type="submit" class="p-2.5 rounded-xl bg-cyan-500 text-white hover:bg-cyan-600 shadow-lg shadow-cyan-500/25 transition-all" title="{{ __('Search') }}">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/></svg>
</button>
<button type="button"
@click="$el.closest('form').querySelectorAll('input[type=text]').forEach(i=>i.value=''); $el.closest('form').querySelectorAll('select').forEach(s=>{s.value=' ';window.HSSelect?.getInstance(s)?.setValue(' ');}); $el.closest('form').dispatchEvent(new Event('submit'));"
class="p-2.5 rounded-xl bg-slate-100 dark:bg-slate-800 text-slate-500 hover:bg-slate-200 dark:hover:bg-slate-700 transition-all" title="{{ __('Reset') }}">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/></svg>
</button>
</div>
</form>
{{-- 桌面表格 --}}
<div class="hidden xl:block overflow-x-auto">
<table class="w-full text-left border-separate border-spacing-y-0">
<thead>
<tr class="bg-slate-50/50 dark:bg-slate-900/10">
<th class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800">{{ __('Role Name') }}</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">{{ __('Affiliation') }}</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">{{ __('Permissions') }}</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">{{ __('Users') }}</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($roles as $role)
<tr class="group hover:bg-slate-50/50 dark:hover:bg-white/[0.02] transition-colors duration-200">
<td class="px-6 py-6">
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-2xl 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 transition-all duration-300 shrink-0">
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5" 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>
</div>
<div class="min-w-0">
<p class="text-base font-extrabold text-slate-800 dark:text-slate-100 group-hover:text-cyan-600 dark:group-hover:text-cyan-400 transition-colors truncate">{{ $role->name }}</p>
@if($role->is_system)
<span class="inline-flex items-center gap-1 text-[10px] font-black text-cyan-500 dark:text-cyan-400 uppercase tracking-widest">
<svg xmlns="http://www.w3.org/2000/svg" class="w-3 h-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect width="18" height="11" x="3" y="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
{{ __('System Role') }}
</span>
@endif
</div>
</div>
</td>
<td class="px-6 py-6">
@if($role->is_system)
<span class="px-2.5 py-1 rounded-full text-[10px] font-black bg-cyan-500/10 text-cyan-600 dark:text-cyan-400 uppercase tracking-widest border border-cyan-500/20">{{ __('System Level') }}</span>
@else
<span class="text-xs font-bold text-slate-500 dark:text-slate-400 tracking-widest uppercase">{{ $role->company->name ?? __('Company Level') }}</span>
@endif
</td>
<td class="px-6 py-6" width="30%">
<div class="flex flex-wrap gap-1 max-w-xs">
@php
$activeModules = ['menu.machines', 'menu.warehouses', 'menu.sales', 'menu.data-config', 'menu.remote', 'menu.special-permission', 'menu.basic', 'menu.permissions'];
$displayPermissions = $role->permissions->filter(function($p) use ($activeModules) {
if ($p->name === 'menu.data-config.sub-account-roles') return false;
if (str_starts_with($p->name, 'menu.')) {
foreach ($activeModules as $module) {
if ($p->name === $module || str_starts_with($p->name, $module . '.')) return true;
}
return false;
}
return true;
});
@endphp
@forelse($displayPermissions->take(5) as $permission)
<span class="px-2 py-0.5 text-[10px] bg-slate-100 dark:bg-slate-800 text-slate-600 dark:text-slate-300 rounded-full border border-slate-200 dark:border-slate-700 uppercase font-black tracking-widest">{{ __($permission->name) }}</span>
@empty
<span class="text-xs font-bold text-slate-400">{{ __('No permissions') }}</span>
@endforelse
@if($displayPermissions->count() > 5)
<div x-data="{ open: false }">
<button type="button" @click="open = true"
class="px-2 py-0.5 text-[10px] bg-slate-100 dark:bg-slate-800 text-slate-500 dark:text-slate-400 rounded-full border border-slate-200 dark:border-slate-700 uppercase font-black tracking-widest hover:bg-cyan-500/10 hover:text-cyan-600 hover:border-cyan-400/30 dark:hover:text-cyan-400 transition-all cursor-pointer">
+{{ $displayPermissions->count() - 5 }}
</button>
{{-- 全螢幕置中彈窗 --}}
<template x-teleport="body">
<div x-show="open"
class="fixed inset-0 z-[100] flex items-center justify-center p-4 overflow-y-auto"
x-cloak>
{{-- 背景遮罩 --}}
<div x-show="open"
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"
@click="open = false"
class="fixed inset-0 bg-slate-900/60 backdrop-blur-sm"></div>
{{-- 內容視窗 --}}
<div x-show="open"
x-transition:enter="transition ease-out duration-300"
x-transition:enter-start="opacity-0 scale-95"
x-transition:enter-end="opacity-100 scale-100"
x-transition:leave="transition ease-in duration-200"
x-transition:leave-start="opacity-100 scale-100"
x-transition:leave-end="opacity-0 scale-95"
class="relative w-full max-w-lg bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-3xl shadow-2xl p-6 md:p-8">
<div class="flex items-center justify-between mb-6">
<div>
<h3 class="text-xl font-bold text-slate-800 dark:text-white">{{ $role->display_name ?: $role->name }}</h3>
<p class="text-xs text-slate-400 mt-1 uppercase tracking-widest font-black">{{ __('All Permissions') }} ({{ $displayPermissions->count() }})</p>
</div>
<button @click="open = false" class="p-2 rounded-full hover:bg-slate-100 dark:hover:bg-slate-800 text-slate-400 transition-colors">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path></svg>
</button>
</div>
<div class="flex flex-wrap gap-2">
@foreach($displayPermissions as $permission)
<span class="px-3 py-1 text-xs bg-slate-50 dark:bg-slate-800/50 text-slate-600 dark:text-slate-300 rounded-full border border-slate-200 dark:border-slate-700 uppercase font-bold tracking-tight">{{ __($permission->name) }}</span>
@endforeach
</div>
</div>
</div>
</template>
</div>
@endif
</div>
</td>
<td class="px-6 py-6 text-center">
<span class="text-sm font-extrabold text-slate-700 dark:text-slate-300">{{ $role->users()->count() }}</span>
</td>
<td class="px-6 py-6 text-right">
<div class="flex items-center justify-end gap-2">
<a href="{{ route($baseRoute . '.edit', $role->id) }}"
class="p-2 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 tooltip" title="{{ __('Edit') }}">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="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>
@if($role->name !== 'super-admin' && (auth()->user()->isSystemAdmin() || !$role->is_system))
<button type="button"
@click="if({{ $role->users()->count() }} > 0) { triggerDeleteWarning('{{ __('Cannot delete role with active users.') }}'); return; } confirmDelete('{{ route($baseRoute . '.destroy', $role->id) }}')"
class="p-2 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 tooltip" title="{{ __('Delete') }}">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="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 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0"/></svg>
</button>
@endif
</div>
</td>
</tr>
@empty
<x-empty-state mode="table" :colspan="5" :message="__('No roles found.')" />
@endforelse
</tbody>
</table>
</div>
{{-- Mobile/Tablet Card Grid --}}
<div class="xl:hidden grid grid-cols-1 md:grid-cols-2 gap-6">
@forelse($roles as $role)
@include('admin.data-config.partials.role-card', ['role' => $role, 'baseRoute' => $baseRoute])
@empty
<x-empty-state :message="__('No roles found.')" />
@endforelse
</div>
{{-- 分頁 --}}
<div class="mt-8 border-t border-slate-100/50 dark:border-slate-800/50 pt-6">
{{ $roles->appends(request()->query())->links('vendor.pagination.luxury', [
'ajax_navigate_event' => 'ajax:navigate:roles',
'per_page_param' => $perPageParam,
]) }}
</div>

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,84 @@
{{-- Mobile Card: 帳號 - 嚴格遵守 SKILL Mobile Card 三區結構 --}}
<div class="luxury-card p-6 rounded-[2rem] border border-slate-100 dark:border-slate-800 bg-white/50 dark:bg-slate-900/50 transition-all duration-300 group">
{{-- 區域一Card Header --}}
<div class="flex items-start justify-between gap-4 mb-6">
<div class="flex items-center gap-4 min-w-0">
<div class="w-14 h-14 rounded-2xl 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 transition-all duration-300 overflow-hidden shadow-sm shrink-0">
@if($user->avatar)
<img src="{{ Storage::url($user->avatar) }}" class="w-full h-full object-cover">
@else
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="M15.75 6a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0ZM4.501 20.118a7.5 7.5 0 0 1 14.998 0A17.933 17.933 0 0 1 12 21.75c-2.676 0-5.216-.584-7.499-1.632Z"/></svg>
@endif
</div>
<div class="min-w-0">
<h3 class="text-base font-extrabold text-slate-800 dark:text-slate-100 truncate hover:text-cyan-600 dark:hover:text-cyan-400 transition-colors tracking-tight cursor-default">{{ $user->name }}</h3>
<p class="text-xs font-mono font-bold text-slate-400 dark:text-slate-500 uppercase tracking-widest truncate">{{ $user->username }}</p>
</div>
</div>
<x-status-badge :status="$user->status ? 'active' : 'disabled'" size="sm" />
</div>
{{-- 區域二Info Grid --}}
<div class="grid grid-cols-2 gap-y-4 mb-6 border-y border-slate-100 dark:border-slate-800/50 py-4">
@if(auth()->user()->isSystemAdmin())
<div>
<p class="text-[10px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-widest mb-1">{{ __('Affiliation') }}</p>
<p class="text-sm font-bold text-slate-700 dark:text-slate-300">
@if($user->company){{ $user->company->name }}
@else<span class="text-cyan-500">{{ __('SYSTEM') }}</span>
@endif
</p>
</div>
@endif
<div>
<p class="text-[10px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-widest mb-1">{{ __('Role') }}</p>
<div class="flex flex-wrap gap-1">
@forelse($user->roles as $role)
<span class="px-2 py-0.5 rounded-full text-[10px] font-black bg-slate-100 dark:bg-slate-800 text-slate-600 dark:text-slate-400 border border-slate-200 dark:border-slate-700 uppercase tracking-widest">{{ $role->name }}</span>
@empty
<span class="text-xs text-slate-400">{{ __('No Role') }}</span>
@endforelse
</div>
</div>
<div class="col-span-2">
<p class="text-[10px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-widest mb-1">{{ __('Contact Info') }}</p>
<div class="flex flex-col gap-0.5">
@if($user->phone)<p class="text-sm font-bold text-slate-700 dark:text-slate-300 tracking-widest">{{ $user->phone }}</p>@endif
@if($user->email)<p class="text-sm font-bold text-slate-600 dark:text-slate-400 tracking-wide">{{ $user->email }}</p>@endif
@if(!$user->phone && !$user->email)<p class="text-sm font-bold text-slate-300 dark:text-slate-600"></p>@endif
</div>
</div>
</div>
{{-- 區域三Action Buttons --}}
<div class="flex items-center gap-3">
@if(!$user->hasRole('super-admin') || auth()->user()->hasRole('super-admin'))
<button type="button" @click="openEditModal(@js($user))"
class="flex-1 flex items-center justify-center gap-2 py-3 rounded-xl bg-slate-50 dark:bg-slate-800 text-slate-500 dark:text-slate-400 font-bold text-xs hover:bg-cyan-500 hover:text-white transition-all duration-300 border border-slate-200/50 dark:border-slate-700/50">
<svg class="w-3.5 h-3.5" 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>
{{ __('Edit') }}
</button>
@if($user->status)
<button type="button" @click="toggleFormAction = '{{ route($baseRoute . '.status.toggle', $user->id) }}'; statusToggleSource = 'list'; isStatusConfirmOpen = true"
class="flex-1 flex items-center justify-center gap-2 py-3 rounded-xl bg-slate-50 dark:bg-slate-800 text-slate-500 dark:text-slate-400 font-bold text-xs hover:bg-amber-500 hover:text-white transition-all duration-300 border border-slate-200/50 dark:border-slate-700/50">
<svg class="w-3.5 h-3.5" 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>
{{ __('Disable') }}
</button>
@else
<button type="button" @click="toggleFormAction = '{{ route($baseRoute . '.status.toggle', $user->id) }}'; $nextTick(() => $refs.statusToggleForm.submit())"
class="flex-1 flex items-center justify-center gap-2 py-3 rounded-xl bg-slate-50 dark:bg-slate-800 text-slate-500 dark:text-slate-400 font-bold text-xs hover:bg-emerald-500 hover:text-white transition-all duration-300 border border-slate-200/50 dark:border-slate-700/50">
<svg class="w-3.5 h-3.5" 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>
{{ __('Enable') }}
</button>
@endif
<button type="button" @click="confirmDelete('{{ route($baseRoute . '.destroy', $user->id) }}')"
class="flex-1 flex items-center justify-center gap-2 py-3 rounded-xl bg-slate-50 dark:bg-slate-800 text-slate-500 dark:text-slate-400 font-bold text-xs hover:bg-rose-500 hover:text-white transition-all duration-300 border border-slate-200/50 dark:border-slate-700/50">
<svg class="w-3.5 h-3.5" 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 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0"/></svg>
{{ __('Delete') }}
</button>
@else
<span class="flex-1 text-center text-[10px] font-black text-slate-300 dark:text-slate-600 uppercase tracking-[0.15em]">{{ __('Protected') }}</span>
@endif
</div>
</div>

View File

@ -0,0 +1,118 @@
{{-- Mobile Card: 角色 - 嚴格遵守 SKILL Mobile Card 三區結構 --}}
<div class="luxury-card p-6 rounded-[2rem] border border-slate-100 dark:border-slate-800 bg-white/50 dark:bg-slate-900/50 transition-all duration-300 group">
{{-- 區域一Card Header --}}
<div class="flex items-start justify-between gap-4 mb-6">
<div class="flex items-center gap-4 min-w-0">
<div class="w-14 h-14 rounded-2xl 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 transition-all duration-300 shadow-sm shrink-0">
<svg xmlns="http://www.w3.org/2000/svg" class="w-6 h-6" 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>
</div>
<div class="min-w-0">
<h3 class="text-base font-extrabold text-slate-800 dark:text-slate-100 truncate hover:text-cyan-600 dark:hover:text-cyan-400 transition-colors tracking-tight cursor-default">{{ $role->name }}</h3>
<p class="text-xs font-bold text-slate-400 dark:text-slate-500 uppercase tracking-widest truncate">
{{ $role->is_system ? __('System Level') : ($role->company->name ?? __('Company Level')) }}
</p>
</div>
</div>
@if($role->is_system)
<span class="px-2.5 py-1 rounded-full text-[10px] font-black bg-cyan-500/10 text-cyan-600 dark:text-cyan-400 uppercase tracking-widest border border-cyan-500/20 shrink-0">{{ __('System') }}</span>
@else
<span class="px-2.5 py-1 rounded-full text-[10px] font-black bg-slate-100 dark:bg-slate-800 text-slate-500 dark:text-slate-400 uppercase tracking-widest border border-slate-200 dark:border-slate-700 shrink-0">{{ __('Tenant') }}</span>
@endif
</div>
{{-- 區域二Info Grid --}}
<div class="grid grid-cols-2 gap-y-4 mb-6 border-y border-slate-100 dark:border-slate-800/50 py-4">
<div>
<p class="text-[10px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-widest mb-1">{{ __('Users') }}</p>
<p class="text-sm font-extrabold text-slate-700 dark:text-slate-300">{{ $role->users()->count() }}</p>
</div>
<div>
<p class="text-[10px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-widest mb-1">{{ __('Permissions') }}</p>
@php
$activeModules = ['menu.machines', 'menu.warehouses', 'menu.sales', 'menu.data-config', 'menu.remote', 'menu.special-permission', 'menu.basic', 'menu.permissions'];
$displayPermissions = $role->permissions->filter(function($p) use ($activeModules) {
if ($p->name === 'menu.data-config.sub-account-roles') return false;
if (str_starts_with($p->name, 'menu.')) {
foreach ($activeModules as $module) {
if ($p->name === $module || str_starts_with($p->name, $module . '.')) return true;
}
return false;
}
return true;
});
@endphp
<p class="text-sm font-bold text-slate-700 dark:text-slate-300">{{ $displayPermissions->count() }}</p>
</div>
@if($displayPermissions->count() > 0)
<div class="col-span-2">
<p class="text-[10px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-widest mb-2">{{ __('Permission Tags') }}</p>
<div class="flex flex-wrap gap-1">
@foreach($displayPermissions->take(4) as $permission)
<span class="px-2 py-0.5 rounded-full text-[10px] font-black bg-slate-100 dark:bg-slate-800 text-slate-600 dark:text-slate-300 border border-slate-200 dark:border-slate-700 uppercase tracking-widest">{{ __($permission->name) }}</span>
@endforeach
@if($displayPermissions->count() > 4)
<div x-data="{ open: false }">
<button type="button" @click="open = true"
class="px-2 py-0.5 rounded-full text-[10px] font-black bg-slate-100 dark:bg-slate-800 text-slate-400 border border-slate-200 dark:border-slate-700 uppercase tracking-widest hover:bg-cyan-500/10 hover:text-cyan-600 hover:border-cyan-400/30 dark:hover:text-cyan-400 transition-all cursor-pointer">
+{{ $displayPermissions->count() - 4 }}
</button>
<template x-teleport="body">
<div x-show="open"
class="fixed inset-0 z-[100] flex items-center justify-center p-4 overflow-y-auto"
x-cloak>
<div x-show="open"
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"
@click="open = false"
class="fixed inset-0 bg-slate-900/60 backdrop-blur-sm"></div>
<div x-show="open"
x-transition:enter="transition ease-out duration-300"
x-transition:enter-start="opacity-0 scale-95"
x-transition:enter-end="opacity-100 scale-100"
x-transition:leave="transition ease-in duration-200"
class="relative w-full max-w-sm bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-3xl shadow-2xl p-6">
<div class="flex items-center justify-between mb-4">
<h3 class="font-bold text-slate-800 dark:text-white">{{ $role->display_name ?: $role->name }}</h3>
<button @click="open = false" class="p-1.5 rounded-full hover:bg-slate-100 dark:hover:bg-slate-800 text-slate-400 transition-colors">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path></svg>
</button>
</div>
<div class="flex flex-wrap gap-1.5">
@foreach($displayPermissions as $permission)
<span class="px-2.5 py-1 text-[10px] bg-slate-50 dark:bg-slate-800/50 text-slate-600 dark:text-slate-300 rounded-full border border-slate-200 dark:border-slate-700 uppercase font-bold tracking-tight">{{ __($permission->name) }}</span>
@endforeach
</div>
</div>
</div>
</template>
</div>
@endif
</div>
</div>
@endif
</div>
{{-- 區域三Action Buttons --}}
<div class="flex items-center gap-3">
<a href="{{ route($baseRoute . '.edit', $role->id) }}"
class="flex-1 flex items-center justify-center gap-2 py-3 rounded-xl bg-slate-50 dark:bg-slate-800 text-slate-500 dark:text-slate-400 font-bold text-xs hover:bg-cyan-500 hover:text-white transition-all duration-300 border border-slate-200/50 dark:border-slate-700/50">
<svg class="w-3.5 h-3.5" 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>
{{ __('Edit') }}
</a>
@if($role->name !== 'super-admin' && (auth()->user()->isSystemAdmin() || !$role->is_system))
<button type="button"
@click="if({{ $role->users()->count() }} > 0) { triggerDeleteWarning('{{ __('Cannot delete role with active users.') }}'); return; } confirmDelete('{{ route($baseRoute . '.destroy', $role->id) }}')"
class="flex-1 flex items-center justify-center gap-2 py-3 rounded-xl bg-slate-50 dark:bg-slate-800 text-slate-500 dark:text-slate-400 font-bold text-xs hover:bg-rose-500 hover:text-white transition-all duration-300 border border-slate-200/50 dark:border-slate-700/50">
<svg class="w-3.5 h-3.5" 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 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0"/></svg>
{{ __('Delete') }}
</button>
@endif
</div>
</div>

View File

@ -0,0 +1,275 @@
@extends('layouts.admin')
@php
$baseRoute = 'admin.permission.accounts';
$roleSelectConfig = [
"placeholder" => __('Select Role'),
"hasSearch" => true,
"searchPlaceholder" => __('Search Role...'),
"isHidePlaceholder" => false,
"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",
"dropdownClasses" => "hs-select-menu w-full bg-white/95 dark:bg-slate-900/95 backdrop-blur-xl 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>'
];
@endphp
@section('content')
<div class="space-y-4 pb-20" x-data="accountManager({
roles: @js($roles),
errors: @js($errors->any()),
oldValues: {
method: @js(old('_method')),
id: @js(old('id')),
name: @js(old('name')),
username: @js(old('username')),
email: @js(old('email')),
phone: @js(old('phone')),
company_id: @js(old('company_id', auth()->user()->isSystemAdmin() ? '' : auth()->user()->company_id)),
role: @js(old('role', '')),
status: @js(old('status', 1))
},
tab: 'accounts',
roleSelectConfig: @js($roleSelectConfig)
})">
<x-page-header
:title="$title"
:subtitle="__('Manage administrative and tenant accounts')"
>
<button @click="openCreateModal()" class="btn-luxury-primary">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15"/></svg>
<span>{{ __('Add Account') }}</span>
</button>
</x-page-header>
<div class="luxury-card rounded-3xl p-8 animate-luxury-in relative overflow-hidden">
<x-luxury-spinner show="isLoadingTable" />
<div id="ajax-accounts-container"
:class="{ 'opacity-30 pointer-events-none': isLoadingTable }"
@ajax:navigate:accounts.prevent="fetchTabContent($event.detail.url, 'ajax-accounts-container')">
@include('admin._shared.account-list', [
'users' => $users,
'baseRoute' => $baseRoute,
'companies' => $companies,
])
</div>
</div>
{{-- User Modal --}}
<div x-show="showModal" class="fixed inset-0 z-[100] overflow-y-auto" x-cloak>
<div class="flex items-center justify-center min-h-screen px-4 pt-4 pb-20 text-center sm:block sm:p-0">
<div x-show="showModal" 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 bg-slate-900/60 backdrop-blur-sm" @click="showModal = false"></div>
<span class="hidden sm:inline-block sm:align-middle sm:h-screen">&#8203;</span>
<div x-show="showModal" x-transition:enter="ease-out duration-300" x-transition:enter-start="opacity-0 translate-y-4 sm:scale-95" x-transition:enter-end="opacity-100 translate-y-0 sm:scale-100" x-transition:leave="ease-in duration-200" x-transition:leave-start="opacity-100 sm:scale-100" x-transition:leave-end="opacity-0 translate-y-4 sm:scale-95"
class="inline-block px-8 py-10 text-left align-bottom transition-all transform luxury-card rounded-3xl dark:bg-slate-900 border-slate-200/50 dark:border-slate-700/50 shadow-2xl sm:my-8 sm:align-middle sm:max-w-2xl sm:w-full overflow-visible">
<div class="flex justify-between items-center mb-8">
<h3 class="text-2xl font-black text-slate-800 dark:text-white font-display tracking-tight" x-text="editing ? '{{ __('Edit Account') }}' : '{{ __('Add Account') }}'"></h3>
<button @click="showModal = false" class="text-slate-400 hover:text-slate-600 dark:hover:text-slate-200 transition-colors">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M6 18L18 6M6 6l12 12"/></svg>
</button>
</div>
<form x-ref="accountForm" :action="!editing ? '{{ route($baseRoute . '.store') }}' : '{{ route($baseRoute) }}/' + currentUser.id" method="POST" class="space-y-6">
@csrf
<template x-if="editing"><input type="hidden" name="_method" value="PUT"></template>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div class="space-y-2">
<label class="text-xs font-black text-slate-500 uppercase tracking-widest pl-1">{{ __('Full Name') }} <span class="text-rose-500">*</span></label>
<input type="text" name="name" x-model="currentUser.name" required class="luxury-input @error('name') border-rose-500 @enderror" placeholder="{{ __('e.g. John Doe') }}">
</div>
<div class="space-y-2">
<label class="text-xs font-black text-slate-500 uppercase tracking-widest pl-1">{{ __('Username') }} <span class="text-rose-500">*</span></label>
<input type="text" name="username" x-model="currentUser.username" required class="luxury-input @error('username') border-rose-500 @enderror" placeholder="{{ __('e.g. johndoe') }}">
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div class="space-y-2">
<label class="text-xs font-black text-slate-500 uppercase tracking-widest pl-1">{{ __('Email') }}</label>
<input type="email" name="email" x-model="currentUser.email" class="luxury-input @error('email') border-rose-500 @enderror" placeholder="{{ __('john@example.com') }}">
</div>
<div class="space-y-2">
<label class="text-xs font-black text-slate-500 uppercase tracking-widest pl-1">{{ __('Phone') }}</label>
<input type="text" name="phone" x-model="currentUser.phone" class="luxury-input @error('phone') border-rose-500 @enderror">
</div>
</div>
@if(auth()->user()->isSystemAdmin())
<div class="space-y-2 relative z-30">
<label class="text-xs font-black text-slate-500 uppercase tracking-widest pl-1">{{ __('Affiliation') }}</label>
<x-searchable-select id="modal-account-company" name="company_id" placeholder="{{ __('SYSTEM') }}" x-model="currentUser.company_id" @change="currentUser.company_id = $event.target.value; updateRoleSelect()">
@foreach($companies as $company)
<option value="{{ $company->id }}" data-title="{{ $company->name }}">{{ $company->name }}</option>
@endforeach
</x-searchable-select>
</div>
@endif
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 relative z-20">
<div class="space-y-2">
<label class="text-xs font-black text-slate-500 uppercase tracking-widest pl-1">{{ __('Role') }} <span class="text-rose-500">*</span></label>
<div id="role-select-wrapper" class="relative">{{-- Dynamic --}}</div>
</div>
<div class="space-y-2">
<label class="text-xs font-black text-slate-500 uppercase tracking-widest pl-1">{{ __('Status') }}</label>
<x-searchable-select id="modal-account-status" name="status" x-model="currentUser.status" :hasSearch="false">
<option value="1">{{ __('Active') }}</option>
<option value="0">{{ __('Disabled') }}</option>
</x-searchable-select>
</div>
</div>
<div class="space-y-2">
<label class="text-xs font-black text-slate-500 uppercase tracking-widest pl-1">
<span x-text="editing ? '{{ __('New Password (leave blank to keep current)') }}' : '{{ __('Password') }}'"></span>
<template x-if="!editing"><span class="text-rose-500">*</span></template>
</label>
<div x-data="{ show: false }" class="relative">
<input :type="show ? 'text' : 'password'" name="password" :required="!editing" class="luxury-input w-full pr-12 @error('password') border-rose-500 @enderror" placeholder="••••••••">
<button type="button" @click="show = !show" class="absolute inset-y-0 end-0 flex items-center z-20 px-4 cursor-pointer text-slate-400 hover:text-cyan-500 transition-colors">
<svg x-show="!show" class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="M2.036 12.322a1.012 1.012 0 010-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178z"/><path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/></svg>
<svg x-show="show" x-cloak class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="M3.98 8.223A10.477 10.477 0 001.934 12C3.226 16.338 7.244 19.5 12 19.5c.993 0 1.953-.138 2.863-.395M6.228 6.228A10.45 10.45 0 0112 4.5c4.756 0 8.773 3.162 10.065 7.498a10.523 10.523 0 01-4.293 5.774M6.228 6.228L3 3m3.228 3.228l3.65 3.65m7.894 7.894L21 21m-3.228-3.228l-3.65-3.65m0 0a3 3 0 10-4.243-4.243m4.242 4.242L9.88 9.88"/></svg>
</button>
</div>
</div>
<div class="flex justify-end gap-x-4 pt-8">
<button type="button" @click="showModal = false" class="btn-luxury-ghost px-8">{{ __('Cancel') }}</button>
<button type="submit" class="btn-luxury-primary px-12"><span x-text="editing ? '{{ __('Update') }}' : '{{ __('Create') }}'"></span></button>
</div>
</form>
</div>
</div>
</div>
<x-delete-confirm-modal :message="__('Are you sure you want to delete this account? This action cannot be undone.')" />
<x-status-confirm-modal :title="__('Confirm Account Deactivation')" :message="__('Are you sure you want to deactivate this account? After deactivating, this account will no longer be able to log in to the system.')" />
<form x-ref="statusToggleForm" :action="toggleFormAction" method="POST" class="hidden">@csrf @method('PATCH')</form>
<div x-show="isWarningModalOpen" class="fixed inset-0 z-[200] overflow-y-auto" x-cloak>
<div class="flex items-center justify-center min-h-screen px-4 text-center sm:block sm:p-0">
<div x-show="isWarningModalOpen" 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 bg-slate-900/60 backdrop-blur-sm" @click="isWarningModalOpen = false"></div>
<span class="hidden sm:inline-block sm:align-middle sm:h-screen">&#8203;</span>
<div x-show="isWarningModalOpen" x-transition:enter="ease-out duration-300" x-transition:enter-start="opacity-0 scale-95" x-transition:enter-end="opacity-100 scale-100" x-transition:leave="ease-in duration-200" x-transition:leave-start="opacity-100 scale-100" x-transition:leave-end="opacity-0 scale-95"
class="inline-block p-8 text-left align-bottom transition-all transform bg-white dark:bg-slate-900 rounded-3xl shadow-2xl sm:my-8 sm:align-middle sm:max-w-lg sm:w-full border border-slate-100 dark:border-slate-800">
<div class="flex items-start gap-6">
<div class="flex items-center justify-center w-12 h-12 bg-rose-100 dark:bg-rose-500/10 rounded-2xl text-rose-600 dark:text-rose-400 shrink-0">
<svg class="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke-width="2.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z"/></svg>
</div>
<div>
<h3 class="text-xl font-black text-slate-800 dark:text-white tracking-tight font-display">{{ __('Operation Failed') }}</h3>
<p class="mt-2 text-sm font-bold text-slate-500 dark:text-slate-400" x-text="deleteWarningMsg"></p>
</div>
</div>
<div class="mt-8 flex justify-end">
<button type="button" @click="isWarningModalOpen = false" class="btn-luxury-ghost px-8">{{ __('Got it') }}</button>
</div>
</div>
</div>
</div>
</div>
@endsection
@section('scripts')
<script>
document.addEventListener('alpine:init', () => {
Alpine.data('accountManager', (initData) => ({
showModal: initData.errors,
editing: initData.oldValues.method === 'PUT' || (initData.oldValues.id && initData.errors),
allRoles: initData.roles,
currentUser: {
id: initData.oldValues.id || '', name: initData.oldValues.name || '',
username: initData.oldValues.username || '', email: initData.oldValues.email || '',
phone: initData.oldValues.phone || '', company_id: initData.oldValues.company_id || '',
role: initData.oldValues.role || '', status: initData.oldValues.status || 1
},
tab: 'accounts',
roleSelectConfig: initData.roleSelectConfig,
isDeleteConfirmOpen: false, deleteFormAction: '',
isStatusConfirmOpen: false, toggleFormAction: '', statusToggleSource: 'list',
isWarningModalOpen: false, deleteWarningMsg: '',
triggerDeleteWarning(msg) { this.deleteWarningMsg = msg; this.isWarningModalOpen = true; },
isLoadingTable: false,
async fetchTabContent(url, containerId) {
if (!url || this.isLoadingTable) return;
this.isLoadingTable = true;
try {
const res = await fetch(url, { headers: { 'X-Requested-With': 'XMLHttpRequest' } });
if (!res.ok) throw new Error('Network error');
const html = await res.text();
const doc = new DOMParser().parseFromString(html, 'text/html');
const newContent = doc.getElementById(containerId);
const container = document.getElementById(containerId);
if (newContent && container) {
container.innerHTML = newContent.innerHTML;
window.history.pushState({}, '', url);
if (window.HSStaticMethods?.autoInit) window.HSStaticMethods.autoInit();
if (window.Alpine?.initTree) window.Alpine.initTree(container);
this.$nextTick(() => {
container.querySelectorAll('select[data-hs-select]').forEach(sel => {
const inst = window.HSSelect?.getInstance(sel);
if (inst && sel.value) inst.setValue(sel.value);
});
});
}
} catch (e) { console.error('AJAX Load Failed:', e); window.location.href = url; } finally { this.isLoadingTable = false; }
},
confirmDelete(action) { this.deleteFormAction = action; this.isDeleteConfirmOpen = true; },
submitConfirmedForm() { if (this.statusToggleSource === 'list') { this.$refs.statusToggleForm.submit(); } else { this.$refs.accountForm.submit(); } },
get filteredRoles() {
const companyId = this.currentUser.company_id;
if (!companyId || companyId.toString().trim() === '' || companyId === ' ') {
return this.allRoles.filter(r => !r.company_id || r.company_id.toString().trim() === '');
}
let companyRoles = this.allRoles.filter(r => r.company_id == companyId);
return companyRoles.length > 0 ? companyRoles : this.allRoles.filter(r => (!r.company_id || r.company_id.toString().trim() === '') && r.name !== 'super-admin');
},
openCreateModal() {
this.editing = false;
const roles = this.filteredRoles;
this.currentUser = { id: '', name: '', username: '', email: '', phone: '', company_id: initData.oldValues.company_id || '', role: roles.length > 0 ? roles[0].name : '', status: 1 };
this.showModal = true;
this.$nextTick(() => this.updateRoleSelect());
},
openEditModal(user) {
this.editing = true;
this.currentUser = { ...user, company_id: user.company_id || ' ', role: user.roles?.length > 0 ? user.roles[0].name : '', status: user.status };
this.showModal = true;
this.$nextTick(() => { this.syncSelect('modal-account-company', this.currentUser.company_id); this.syncSelect('modal-account-status', this.currentUser.status); this.updateRoleSelect(); });
},
syncSelect(id, value) {
this.$nextTick(() => {
const el = document.getElementById(id);
if (el) { const v = (value !== undefined && value !== null && value.toString().trim() !== '') ? value.toString() : ' '; el.value = v; el.dispatchEvent(new Event('change', { bubbles: true })); window.HSSelect?.getInstance(el)?.setValue(v); }
});
},
updateRoleSelect() {
this.$nextTick(() => {
const wrapper = document.getElementById('role-select-wrapper');
if (!wrapper) return;
const cleanConfig = JSON.parse(JSON.stringify(this.roleSelectConfig), (k, v) => typeof v === 'string' ? v.replace(/\r?\n|\r/g, ' ').trim() : v);
const roles = this.filteredRoles;
if (roles.length > 0 && !roles.find(r => r.name === this.currentUser.role)) { this.currentUser.role = roles[0].name; } else if (roles.length === 0) { this.currentUser.role = ''; }
wrapper.querySelectorAll('select').forEach(s => { try { window.HSSelect?.getInstance(s)?.destroy(); } catch(e){} });
wrapper.innerHTML = '';
const selectEl = document.createElement('select');
selectEl.name = 'role'; selectEl.id = 'modal-account-role-' + Date.now(); selectEl.className = 'hidden'; selectEl.setAttribute('data-hs-select', JSON.stringify(cleanConfig));
if (roles.length === 0) { const opt = document.createElement('option'); opt.value = ''; opt.textContent = '{{ __("No roles available") }}'; opt.disabled = true; opt.selected = true; selectEl.appendChild(opt); }
else { roles.forEach(r => { const opt = document.createElement('option'); opt.value = r.name; opt.textContent = r.name; opt.setAttribute('data-title', r.name); if (r.name === this.currentUser.role) opt.selected = true; selectEl.appendChild(opt); }); }
wrapper.appendChild(selectEl);
selectEl.addEventListener('change', e => { this.currentUser.role = e.target.value; });
this._roleGeneration = (this._roleGeneration || 0) + 1;
const gen = this._roleGeneration;
const waitForHSSelect = (n=0) => { if (gen !== this._roleGeneration) return; const s = window.HSSelect?.getInstance(selectEl); if (s) s.setValue(this.currentUser.role || ''); else if (n < 20) setTimeout(() => waitForHSSelect(n+1), 50); };
const initPreline = (n=0) => { if (gen !== this._roleGeneration) return; if (window.HSStaticMethods?.autoInit) { try { window.HSStaticMethods.autoInit(['select']); waitForHSSelect(); } catch(e){} } else if (n < 50) setTimeout(() => initPreline(n+1), 50); };
initPreline();
});
},
init() {
this.$watch('currentUser.company_id', (value) => { this.syncSelect('modal-account-company', value); if (this.filteredRoles.length > 0 && !this.filteredRoles.find(r => r.name === this.currentUser.role)) { this.currentUser.role = this.filteredRoles[0].name; } this.updateRoleSelect(); });
this.$watch('currentUser.status', value => this.syncSelect('modal-account-status', value));
this.$nextTick(() => this.updateRoleSelect());
}
}));
});
</script>
@endsection

View File

@ -2,7 +2,6 @@
@section('content') @section('content')
<div class="space-y-6" x-data="{ <div class="space-y-6" x-data="{
selectedPermissions: {{ json_encode($role->permissions->pluck('name')->toArray()) }},
@php @php
$isSystemLevel = $role->is_system || (!$role->exists && auth()->user()->isSystemAdmin() && !request()->routeIs('*.sub-account-roles.*')); $isSystemLevel = $role->is_system || (!$role->exists && auth()->user()->isSystemAdmin() && !request()->routeIs('*.sub-account-roles.*'));
$availablePermissions = $all_permissions->flatten(); $availablePermissions = $all_permissions->flatten();
@ -12,8 +11,14 @@
!str_starts_with($p->name, 'menu.permissions'); !str_starts_with($p->name, 'menu.permissions');
}); });
} }
$availableNames = $availablePermissions->pluck('name')->toArray();
$initialSelected = collect($role->permissions->pluck('name')->toArray())
->intersect($availableNames)
->values()
->toArray();
@endphp @endphp
availableCount: {{ $availablePermissions->count() }}, selectedPermissions: {{ json_encode($initialSelected) }},
availableCount: {{ count($availableNames) }},
activeCategory: '', activeCategory: '',
toggleCategory(category, permissions) { toggleCategory(category, permissions) {
const allSelected = permissions.every(p => this.selectedPermissions.includes(p)); const allSelected = permissions.every(p => this.selectedPermissions.includes(p));

View File

@ -1,296 +1,92 @@
@extends('layouts.admin') @extends('layouts.admin')
@php @php
$routeName = request()->route()->getName(); $routeName = request()->route()->getName();
$baseRoute = str_contains($routeName, 'sub-account-roles') ? 'admin.data-config.sub-account-roles' : 'admin.permission.roles'; $baseRoute = str_contains($routeName, 'sub-account-roles') ? 'admin.data-config.sub-account-roles' : 'admin.permission.roles';
@endphp @endphp
@section('content') @section('content')
<div class="space-y-6" x-data="{ <div class="space-y-4 pb-20" x-data="{
showModal: {{ $errors->any() ? 'true' : 'false' }}, isLoadingTable: false,
isEdit: {{ (old('_method') == 'PUT' || request()->has('edit')) ? 'true' : 'false' }},
roleId: '{{ old('roleId', '') }}',
roleName: '{{ old('name', '') }}',
rolePermissions: @js(old('permissions', [])),
currentUserRoleIds: @js($currentUserRoleIds ?? []),
isSystem: {{ old('is_system', '0') }},
modalTitle: '{{ $errors->any() && old('_method') == 'PUT' ? __('Edit Role') : ($errors->any() ? __('Create Role') : __('Create Role')) }}',
openModal(edit = false, id = '', name = '', permissions = [], isSys = false) {
this.isEdit = edit;
this.roleId = id;
this.roleName = name;
this.rolePermissions = Array.isArray(permissions) ? permissions : (typeof permissions === 'string' ? JSON.parse(permissions) : []);
this.isSystem = isSys;
this.modalTitle = edit ? '{{ __('Edit Role') }}' : '{{ __('Create Role') }}';
this.showModal = true;
},
isWarningModalOpen: false, isWarningModalOpen: false,
deleteWarningMsg: '', deleteWarningMsg: '',
triggerDeleteWarning(msg) {
this.deleteWarningMsg = msg;
this.isWarningModalOpen = true;
},
isDeleteConfirmOpen: false, isDeleteConfirmOpen: false,
deleteFormAction: '', deleteFormAction: '',
confirmDelete(action) { triggerDeleteWarning(msg) { this.deleteWarningMsg = msg; this.isWarningModalOpen = true; },
this.deleteFormAction = action; confirmDelete(action) { this.deleteFormAction = action; this.isDeleteConfirmOpen = true; },
this.isDeleteConfirmOpen = true; async fetchTabContent(url, containerId) {
},
isLoadingTable: false,
async fetchPage(url) {
if (!url || this.isLoadingTable) return; if (!url || this.isLoadingTable) return;
this.isLoadingTable = true; this.isLoadingTable = true;
try { try {
const response = await fetch(url, { const res = await fetch(url, { headers: { 'X-Requested-With': 'XMLHttpRequest' } });
headers: { if (!res.ok) throw new Error('Network error');
'X-Requested-With': 'XMLHttpRequest' const html = await res.text();
} const doc = new DOMParser().parseFromString(html, 'text/html');
}); const newContent = doc.getElementById(containerId);
const container = document.getElementById(containerId);
if (!response.ok) throw new Error('Network response was not ok'); if (newContent && container) {
container.innerHTML = newContent.innerHTML;
const html = await response.text(); window.history.pushState({}, '', url);
const parser = new DOMParser(); if (window.HSStaticMethods?.autoInit) window.HSStaticMethods.autoInit();
const doc = parser.parseFromString(html, 'text/html'); if (window.Alpine?.initTree) window.Alpine.initTree(container);
const newContent = doc.querySelector('#ajax-content-container'); setTimeout(() => {
container.querySelectorAll('select[data-hs-select]').forEach(sel => {
if (newContent) { const inst = window.HSSelect?.getInstance(sel);
document.querySelector('#ajax-content-container').innerHTML = newContent.innerHTML; if (inst && sel.value) inst.setValue(sel.value);
history.pushState(null, '', url); });
if (window.HSStaticMethods && window.HSStaticMethods.autoInit) { }, 100);
window.HSStaticMethods.autoInit();
}
} }
} catch (error) { } catch (e) { console.error('Fetch failed:', e); window.location.href = url; } finally { this.isLoadingTable = false; }
console.error('Fetch error:', error);
window.location.href = url;
} finally {
this.isLoadingTable = false;
}
},
init() {
window.addEventListener('popstate', () => {
this.fetchPage(window.location.href);
});
} }
}"> }">
<!-- Header -->
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-6"> <x-page-header
<div> :title="$title"
<h1 class="text-3xl font-black text-slate-800 dark:text-white font-display tracking-tight">{{ $title }}</h1> :subtitle="__('Define and manage security roles and permissions.')"
<p class="text-sm font-bold text-slate-500 dark:text-slate-400 mt-1 uppercase tracking-widest">{{ __('Define and manage security roles and permissions.') }}</p> >
</div> <a href="{{ route($baseRoute . '.create') }}" class="btn-luxury-primary">
<a href="{{ route($baseRoute . '.create') }}" class="btn-luxury-primary text-sm"> <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14"/><path d="M12 5v14"/></svg>
<span>{{ __('Add Role') }}</span> <span>{{ __('Add Role') }}</span>
</a> </a>
</div> </x-page-header>
<!-- Content Container with Loading State --> <div class="luxury-card rounded-3xl p-8 animate-luxury-in relative overflow-hidden">
<div id="ajax-content-container" <x-luxury-spinner show="isLoadingTable" />
class="relative transition-all duration-500 min-h-[400px]"
:class="isLoadingTable ? 'opacity-40 blur-[2px] pointer-events-none' : 'opacity-100 blur-0'"
@ajax:navigate.prevent.stop="fetchPage($event.detail.url)"
@click="if ($event.target.closest('a') && $event.target.closest('a').href && ($event.target.closest('a').href.includes('page=') || $event.target.closest('a').href.includes('per_page='))) { $event.preventDefault(); fetchPage($event.target.closest('a').href); }">
<!-- Loading Spinner Overlay -->
<div x-show="isLoadingTable"
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-300"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
class="absolute inset-0 z-50 flex flex-col items-center justify-center bg-white/40 dark:bg-slate-900/40 backdrop-blur-[1px] 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.5" d="M19.428 15.428a2 2 0 00-1.022-.547l-2.387-.477a6 6 0 00-3.86.517l-.318.158a6 6 0 01-3.86.517L6.05 15.21a2 2 0 00-1.806.547M8 4h8l-1 1v5.172a2 2 0 00.586 1.414l5 5c1.26 1.26.367 3.414-1.415 3.414H4.828c-1.782 0-2.674-2.154-1.414-3.414l5-5A2 2 0 009 10.172V5L8 4z" />
</svg>
</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>
<!-- Roles Content (Integrated Card) --> <div id="ajax-roles-container"
<div class="luxury-card rounded-3xl p-8 animate-luxury-in"> :class="{ 'opacity-30 pointer-events-none': isLoadingTable }"
<!-- Toolbar --> @ajax:navigate:roles.prevent="fetchTabContent($event.detail.url, 'ajax-roles-container')">
<div class="flex flex-col md:flex-row md:items-center justify-between gap-6 mb-10"> @include('admin._shared.role-list', [
<form action="{{ route($baseRoute) }}" method="GET" 'roles' => $roles,
class="flex flex-col md:flex-row md:items-center gap-4" 'baseRoute' => $baseRoute,
@submit.prevent="fetchPage($el.action + '?' + new URLSearchParams(new FormData($el)).toString())"> 'companies' => $companies,
<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" stroke-linecap="round" stroke-linejoin="round">
<circle cx="11" cy="11" r="8"></circle>
<line x1="21" x2="21" x2="16.65" y2="16.65"></line>
</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 roles...') }}">
</div>
@if(auth()->user()->isSystemAdmin())
<div class="relative">
<x-searchable-select
name="company_id"
:options="$companies"
:selected="request('company_id')"
placeholder="{{ __('All Affiliations') }}"
class="w-full md:w-auto min-w-[280px]"
onchange="this.form.dispatchEvent(new Event('submit'))"
>
<option value="system" {{ request('company_id') === 'system' ? 'selected' : '' }} data-title="{{ __('System Level') }}">{{ __('System Level') }}</option>
</x-searchable-select>
</div>
@endif
<input type="hidden" name="per_page" value="{{ request('per_page', 10) }}">
<button type="submit" class="hidden"></button>
</form>
</div>
<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">{{ __('Role Name') }}</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">{{ __('Affiliation') }}</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">{{ __('Permissions') }}</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">{{ __('Users') }}</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($roles as $role)
<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-3">
<div class="w-10 h-10 rounded-2xl 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 transition-all duration-300">
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5" 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>
</div>
<span class="text-base font-extrabold text-slate-800 dark:text-slate-100 group-hover:text-cyan-600 dark:group-hover:text-cyan-400 transition-colors">{{ $role->name }}</span>
@if($role->is_system)
<span class="p-1.5 bg-cyan-50 dark:bg-cyan-900/30 text-cyan-600 dark:text-cyan-400 rounded-lg tooltip" title="{{ __('System Role') }}">
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
</span>
@endif
</div>
</td>
<td class="px-6 py-6">
@if($role->is_system)
<span class="px-2.5 py-1 rounded-lg text-xs font-bold bg-cyan-500/10 text-cyan-600 dark:text-cyan-400 uppercase tracking-widest">
{{ __('System Level') }}
</span>
@else
<span class="text-xs font-bold text-slate-500 dark:text-slate-400 tracking-widest uppercase">
{{ $role->company->name ?? __('Company Level') }}
</span>
@endif
</td>
<td class="px-6 py-6" width="30%">
<div class="flex flex-wrap gap-1 max-w-xs">
@forelse($role->permissions->take(6) as $permission)
<span class="px-2 py-0.5 text-xs bg-slate-100 dark:bg-slate-800 text-slate-600 dark:text-slate-300 rounded border border-slate-200 dark:border-slate-700 uppercase font-bold tracking-widest">{{ __($permission->name) }}</span>
@empty
<span class="text-xs font-bold text-slate-500 dark:text-slate-400 tracking-widest">{{ __('No permissions') }}</span>
@endforelse
@if($role->permissions->count() > 6)
<span class="px-2 py-0.5 text-xs bg-slate-100 dark:bg-slate-800 text-slate-500 dark:text-slate-400 rounded border border-slate-200 dark:border-slate-700 uppercase font-bold tracking-widest">+{{ $role->permissions->count() - 6 }}</span>
@endif
</div>
</td>
<td class="px-6 py-6 text-center">
<span class="text-sm font-extrabold text-slate-700 dark:text-slate-300">{{ $role->users()->count() }}</span>
</td>
<td class="px-6 py-6 text-right">
<div class="flex items-center justify-end gap-2">
<a href="{{ route($baseRoute . '.edit', $role->id) }}" class="p-2 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 tooltip" title="{{ __('Edit') }}">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="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>
@if($role->name !== 'super-admin' && (auth()->user()->isSystemAdmin() || !$role->is_system))
<form action="{{ route($baseRoute . '.destroy', $role->id) }}" method="POST" @submit.prevent="if({{ $role->users()->count() }} > 0) { triggerDeleteWarning('{{ __('Cannot delete role with active users.') }}'); return; } confirmDelete('{{ route($baseRoute . '.destroy', $role->id) }}')" class="inline text-slate-400">
@csrf
@method('DELETE')
<button type="submit" class="p-2 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 tooltip" title="{{ __('Delete') }}">
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/><line x1="10" x2="10" y1="11" y2="17"/><line x1="14" x2="14" y1="11" y2="17"/></svg>
</button>
</form>
@endif
</div>
</td>
</tr>
@empty
<tr>
<td colspan="5" class="px-6 py-20 text-center">
<div class="flex flex-col items-center justify-center gap-4 text-slate-400">
<svg xmlns="http://www.w3.org/2000/svg" class="w-16 h-16 opacity-20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round"><rect width="18" height="18" x="3" y="3" rx="2"/><path d="M12 8v8"/><path d="M8 12h8"/></svg>
<p class="text-lg font-bold">{{ __('No roles found.') }}</p>
</div>
</td>
</tr>
@endforelse
</tbody>
</table>
</div>
<div class="mt-8 border-t border-slate-100 dark:border-slate-800 pt-6">
{{ $roles->links('vendor.pagination.luxury') }}
</div> </div>
</div> </div>
</div>
<!-- Global Delete Warning Modal --> {{-- Delete Warning Modal --}}
<div x-show="isWarningModalOpen" class="fixed inset-0 z-[200] overflow-y-auto" x-cloak> <div x-show="isWarningModalOpen" class="fixed inset-0 z-[200] overflow-y-auto" x-cloak>
<div class="flex items-center justify-center min-h-screen px-4 pt-4 pb-20 text-center sm:block sm:p-0"> <div class="flex items-center justify-center min-h-screen px-4 text-center sm:block sm:p-0">
<div x-show="isWarningModalOpen" x-transition:enter="ease-out duration-300" <div x-show="isWarningModalOpen" 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 bg-slate-900/60 backdrop-blur-sm" @click="isWarningModalOpen = false"></div>
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 transition-opacity bg-slate-900/60 backdrop-blur-sm"
@click="isWarningModalOpen = false"></div>
<span class="hidden sm:inline-block sm:align-middle sm:h-screen">&#8203;</span> <span class="hidden sm:inline-block sm:align-middle sm:h-screen">&#8203;</span>
<div x-show="isWarningModalOpen" x-transition:enter="ease-out duration-300" x-transition:enter-start="opacity-0 scale-95" x-transition:enter-end="opacity-100 scale-100" x-transition:leave="ease-in duration-200" x-transition:leave-start="opacity-100 scale-100" x-transition:leave-end="opacity-0 scale-95"
<div x-show="isWarningModalOpen" x-transition:enter="ease-out duration-300" class="inline-block p-8 text-left align-bottom transition-all transform bg-white dark:bg-slate-900 rounded-3xl shadow-2xl sm:my-8 sm:align-middle sm:max-w-lg sm:w-full border border-slate-100 dark:border-slate-800">
x-transition:enter-start="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95" <div class="flex items-start gap-6">
x-transition:enter-end="opacity-100 translate-y-0 sm:scale-100" x-transition:leave="ease-in duration-200" <div class="flex items-center justify-center w-12 h-12 bg-rose-100 dark:bg-rose-500/10 rounded-2xl text-rose-600 dark:text-rose-400 shrink-0">
x-transition:leave-start="opacity-100 translate-y-0 sm:scale-100" <svg class="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke-width="2.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z"/></svg>
x-transition:leave-end="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
class="inline-block px-4 pt-5 pb-4 overflow-hidden text-left align-bottom transition-all transform bg-white dark:bg-slate-900 rounded-3xl shadow-2xl sm:my-8 sm:align-middle sm:max-w-lg sm:w-full sm:p-8 border border-slate-100 dark:border-slate-800">
<div class="sm:flex sm:items-start">
<div class="flex items-center justify-center flex-shrink-0 w-12 h-12 mx-auto bg-rose-100 dark:bg-rose-500/10 rounded-2xl sm:mx-0 sm:h-12 sm:w-12 text-rose-600 dark:text-rose-400">
<svg class="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke-width="2.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" />
</svg>
</div> </div>
<div class="mt-3 text-center sm:mt-0 sm:ml-6 sm:text-left"> <div>
<h3 class="text-xl font-black text-slate-800 dark:text-white leading-6 tracking-tight outfit-font"> <h3 class="text-xl font-black text-slate-800 dark:text-white tracking-tight font-display">{{ __('Cannot Delete Role') }}</h3>
{{ __('Cannot Delete Role') }} <p class="mt-2 text-sm font-bold text-slate-500 dark:text-slate-400" x-text="deleteWarningMsg"></p>
</h3>
<div class="mt-4">
<p class="text-sm font-bold text-slate-500 dark:text-slate-400 leading-relaxed" x-text="deleteWarningMsg"></p>
</div>
</div> </div>
</div> </div>
<div class="mt-8 sm:mt-10 sm:flex sm:flex-row-reverse"> <div class="mt-8 flex justify-end">
<button type="button" @click="isWarningModalOpen = false" <button type="button" @click="isWarningModalOpen = false" class="btn-luxury-ghost px-8">{{ __('Got it') }}</button>
class="inline-flex justify-center w-full px-8 py-3 text-sm font-black text-white transition-all bg-slate-800 dark:bg-slate-700 rounded-xl hover:bg-slate-900 dark:hover:bg-slate-600 sm:w-auto tracking-widest uppercase">
{{ __('Got it') }}
</button>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<!-- Global Delete Confirm Modal -->
<x-delete-confirm-modal :message="__('Are you sure you want to delete this role? This action cannot be undone.')" /> <x-delete-confirm-modal :message="__('Are you sure you want to delete this role? This action cannot be undone.')" />
</div> </div>

View File

@ -1,121 +1,195 @@
@extends('layouts.admin') @extends('layouts.admin')
@section('content') @section('content')
<div class="px-4 sm:px-6 lg:px-8 py-8 w-full max-w-9xl mx-auto" x-data="{ <div class="space-y-4 pb-20" x-data="{
showCreateModal: false, showCreateModal: false,
selectedMachine: '', selectedMachine: '',
name: '', name: '',
expiresDays: 7, expiresDays: 7,
customCode: '', customCode: '',
useCustomCode: false, isLoadingTable: false,
generateRandomCode() { generateRandomCode() {
this.customCode = Math.floor(Math.random() * 90000000 + 10000000).toString(); this.customCode = Math.floor(Math.random() * 90000000 + 10000000).toString();
},
async fetchPage(url) {
if (!url || this.isLoadingTable) return;
this.isLoadingTable = true;
try {
const res = await fetch(url, { headers: { 'X-Requested-With': 'XMLHttpRequest' } });
const html = await res.text();
const doc = new DOMParser().parseFromString(html, 'text/html');
const newContent = doc.querySelector('#ajax-content-container');
if (newContent) {
document.querySelector('#ajax-content-container').innerHTML = newContent.innerHTML;
window.history.pushState({}, '', url);
if (window.HSStaticMethods?.autoInit) window.HSStaticMethods.autoInit();
}
} catch (e) {
console.error('AJAX Load Failed:', e);
} finally {
this.isLoadingTable = false;
}
} }
}"> }">
{{-- Page Header --}} {{-- Page Header --}}
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-8"> <x-page-header
<div> :title="__('Pass Codes')"
<h1 class="text-2xl sm:text-3xl font-black text-slate-800 dark:text-white font-display tracking-tight">{{ __('Pass Codes') }}</h1> :subtitle="__('Manage multi-use authorization codes for testing and maintenance')"
<p class="text-sm font-bold text-slate-500 dark:text-slate-400 mt-1 uppercase tracking-widest">{{ __('Manage multi-use authorization codes for testing and maintenance') }}</p> >
</div> <button @click="showCreateModal = true; generateRandomCode()" class="luxury-btn-primary group flex items-center gap-2">
<button @click="showCreateModal = true; generateRandomCode()" class="luxury-btn-primary group"> <svg class="w-5 h-5 group-hover:rotate-90 transition-transform duration-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<svg class="w-5 h-5 mr-2 group-hover:rotate-90 transition-transform duration-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M12 4v16m8-8H4" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M12 4v16m8-8H4" />
</svg> </svg>
{{ __('Create Pass Code') }} <span class="text-sm sm:text-base">{{ __('Create Pass Code') }}</span>
</button> </button>
</div> </x-page-header>
{{-- Search & Filters --}} {{-- Main Content Card --}}
<div class="luxury-card rounded-3xl p-6 mb-8"> <div class="luxury-card rounded-3xl p-8 animate-luxury-in relative overflow-hidden">
<form action="{{ route('admin.sales.pass-codes') }}" method="GET" class="flex flex-col md:flex-row gap-4"> <x-luxury-spinner show="isLoadingTable" />
<div class="relative flex-1 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">
<circle cx="11" cy="11" r="8" /><path d="m21 21-4.3-4.3" />
</svg>
</span>
<input type="text" name="search" value="{{ request('search') }}" class="py-2.5 pl-12 pr-6 block w-full luxury-input" placeholder="{{ __('Search by code, name or machine...') }}">
</div>
<button type="submit" class="luxury-btn-secondary px-8">
{{ __('Search') }}
</button>
</form>
</div>
{{-- Table --}} <div id="ajax-content-container" :class="{ 'opacity-30 pointer-events-none': isLoadingTable }">
<div class="luxury-card rounded-3xl overflow-hidden">
<div class="overflow-x-auto"> <x-search-bar
<table class="w-full text-left border-separate border-spacing-0"> :action="route('admin.sales.pass-codes')"
<thead> :value="request('search')"
<tr class="bg-slate-50/50 dark:bg-slate-800/50"> :placeholder="__('Search by code, name or machine...')"
<th class="px-6 py-4 text-xs font-black uppercase tracking-wider text-slate-500 dark:text-slate-400 border-b border-slate-100 dark:border-slate-700">{{ __('Pass Code') }}</th> />
<th class="px-6 py-4 text-xs font-black uppercase tracking-wider text-slate-500 dark:text-slate-400 border-b border-slate-100 dark:border-slate-700">{{ __('Name / Machine') }}</th>
<th class="px-6 py-4 text-xs font-black uppercase tracking-wider text-slate-500 dark:text-slate-400 border-b border-slate-100 dark:border-slate-700">{{ __('Expires At') }}</th> {{-- Table (Desktop) --}}
<th class="px-6 py-4 text-xs font-black uppercase tracking-wider text-slate-500 dark:text-slate-400 border-b border-slate-100 dark:border-slate-700">{{ __('Status') }}</th> <div class="hidden xl:block overflow-x-auto">
<th class="px-6 py-4 text-xs font-black uppercase tracking-wider text-slate-500 dark:text-slate-400 border-b border-slate-100 dark:border-slate-700 text-right">{{ __('Actions') }}</th> <table class="w-full text-left border-separate border-spacing-0">
</tr> <thead>
</thead> <tr class="bg-slate-50/50 dark:bg-slate-900/10">
<tbody class="divide-y divide-slate-100 dark:divide-slate-700"> <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">
@forelse ($passCodes as $code) {{ __('Pass Code') }}
<tr class="group hover:bg-slate-50/50 dark:hover:bg-slate-800/30 transition-colors"> </th>
<td class="px-6 py-4 whitespace-nowrap"> <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">
<span class="text-lg font-black font-mono text-slate-700 dark:text-slate-200 tracking-widest bg-white dark:bg-slate-900 px-4 py-1.5 rounded-xl border border-slate-200 dark:border-slate-700 shadow-sm group-hover:border-cyan-500/50 transition-colors"> {{ __('Name / Machine') }}
{{ $code->code }} </th>
</span> <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">
</td> {{ __('Expires At') }}
<td class="px-6 py-4"> </th>
<div class="text-sm font-black text-slate-800 dark:text-white">{{ $code->name }}</div> <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">
<div class="text-xs font-bold text-slate-400 uppercase tracking-tighter">{{ $code->machine->name }}</div> {{ __('Status') }}
</td> </th>
<td class="px-6 py-4 whitespace-nowrap"> <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">
@if($code->expires_at) {{ __('Actions') }}
<div class="text-sm font-bold text-slate-600 dark:text-slate-300">{{ $code->expires_at->format('Y-m-d H:i') }}</div> </th>
<div class="text-xs font-medium {{ $code->expires_at->isPast() ? 'text-rose-500' : 'text-slate-400' }}"> </tr>
{{ $code->expires_at->isPast() ? __('Expired') : $code->expires_at->diffForHumans() }} </thead>
<tbody class="divide-y divide-slate-100 dark:divide-slate-800/80">
@forelse ($passCodes as $code)
<tr class="group hover:bg-slate-50/50 dark:hover:bg-white/[0.02] transition-colors duration-200">
<td class="px-6 py-6 whitespace-nowrap">
<span class="text-lg font-black font-mono text-slate-700 dark:text-slate-200 tracking-widest bg-white dark:bg-slate-900 px-4 py-1.5 rounded-xl border border-slate-200 dark:border-slate-700 shadow-sm group-hover:border-cyan-500/50 transition-colors">
{{ $code->code }}
</span>
</td>
<td class="px-6 py-6">
<div class="text-base font-extrabold text-slate-800 dark:text-slate-100 tracking-tight">{{ $code->name }}</div>
<div class="text-xs font-bold text-slate-400 uppercase tracking-widest">{{ $code->machine->name }}</div>
</td>
<td class="px-6 py-6 whitespace-nowrap">
@if($code->expires_at)
<div class="text-sm font-black text-slate-600 dark:text-slate-300 font-mono tracking-widest">{{ $code->expires_at->format('Y-m-d H:i') }}</div>
<div class="text-xs font-bold {{ $code->expires_at->isPast() ? 'text-rose-500' : 'text-slate-400' }} mt-0.5">
{{ $code->expires_at->isPast() ? __('Expired') : $code->expires_at->diffForHumans() }}
</div>
@else
<span class="text-xs font-black uppercase tracking-widest text-emerald-500 bg-emerald-500/10 px-2 py-0.5 rounded-lg border border-emerald-500/20">{{ __('Permanent') }}</span>
@endif
</td>
<td class="px-6 py-6 whitespace-nowrap">
<x-status-badge :status="$code->isValid() ? 'active' : 'disabled'" />
</td>
<td class="px-6 py-6 whitespace-nowrap text-right">
<div class="flex items-center justify-end gap-2">
<form action="{{ route('admin.sales.pass-codes.destroy', $code) }}" method="POST" onsubmit="return confirm('{{ __('Are you sure you want to delete this pass code?') }}')">
@csrf
@method('DELETE')
<button type="submit" class="p-2 text-rose-500 hover:bg-rose-500/10 rounded-xl transition-all duration-200" title="{{ __('Delete Code') }}">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v2m3 4h.01" />
</svg>
</button>
</form>
</div> </div>
@else </td>
<span class="text-xs font-black uppercase tracking-widest text-emerald-500">{{ __('Permanent') }}</span> </tr>
@endif @empty
</td> <x-empty-state mode="table" :colspan="5" :message="__('No pass codes found')" />
<td class="px-6 py-4 whitespace-nowrap"> @endforelse
@php </tbody>
$isValid = $code->isValid(); </table>
@endphp
<span class="px-3 py-1 text-xs font-black uppercase tracking-widest rounded-full border {{ $isValid ? 'bg-emerald-50 text-emerald-600 border-emerald-100 dark:bg-emerald-500/10 dark:text-emerald-400 dark:border-emerald-500/20' : 'bg-slate-100 text-slate-500 border-slate-200 dark:bg-slate-700 dark:text-slate-400 dark:border-slate-600' }}">
{{ $isValid ? __('Active') : __('Inactive') }}
</span>
</td>
<td class="px-6 py-4 whitespace-nowrap text-right">
<div class="flex items-center justify-end gap-2">
<form action="{{ route('admin.sales.pass-codes.destroy', $code) }}" method="POST" onsubmit="return confirm('{{ __('Are you sure you want to delete this pass code?') }}')">
@csrf
@method('DELETE')
<button type="submit" class="p-2 text-rose-500 hover:bg-rose-50 dark:hover:bg-rose-500/10 rounded-xl transition-colors">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v2m3 4h.01" />
</svg>
</button>
</form>
</div>
</td>
</tr>
@empty
<tr>
<td colspan="5" class="px-6 py-12 text-center">
<p class="text-slate-500 dark:text-slate-400 font-bold uppercase tracking-widest text-xs">{{ __('No pass codes found') }}</p>
</td>
</tr>
@endforelse
</tbody>
</table>
</div>
@if ($passCodes->hasPages())
<div class="px-6 py-4 border-t border-slate-100 dark:border-slate-700">
{{ $passCodes->links() }}
</div> </div>
@endif
{{-- Card Grid (Mobile) --}}
<div class="xl:hidden grid grid-cols-1 md:grid-cols-2 gap-6">
@forelse ($passCodes as $code)
<div class="luxury-card p-6 rounded-[2rem] border border-slate-100 dark:border-slate-800 bg-white/50 dark:bg-slate-900/50 transition-all duration-300 group">
{{-- Card Header --}}
<div class="flex items-start justify-between gap-4 mb-6">
<div class="flex items-center gap-4 min-w-0">
<div class="w-14 h-14 rounded-2xl 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 transition-all duration-300 overflow-hidden shadow-sm shrink-0">
<svg class="w-7 h-7" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
</svg>
</div>
<div class="min-w-0">
<h3 class="text-base font-extrabold text-slate-800 dark:text-slate-100 truncate hover:text-cyan-600 dark:hover:text-cyan-400 transition-colors tracking-tight">
{{ $code->code }}
</h3>
<p class="text-xs font-mono font-bold text-slate-400 dark:text-slate-500 uppercase tracking-widest truncate">
{{ $code->name }}
</p>
</div>
</div>
<x-status-badge :status="$code->isValid() ? 'active' : 'disabled'" size="sm" />
</div>
{{-- Info Grid --}}
<div class="grid grid-cols-2 gap-y-4 mb-6 border-y border-slate-100 dark:border-slate-800/50 py-4">
<div>
<p class="text-[10px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-widest mb-1">{{ __('Machine') }}</p>
<p class="text-sm font-bold text-slate-700 dark:text-slate-300 truncate">{{ $code->machine->name }}</p>
</div>
<div>
<p class="text-[10px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-widest mb-1">{{ __('Expires At') }}</p>
<p class="text-sm font-bold text-slate-700 dark:text-slate-300 font-mono tracking-tighter">
{{ $code->expires_at ? $code->expires_at->format('Y-m-d H:i') : __('Permanent') }}
</p>
</div>
</div>
{{-- Action Buttons --}}
<div class="flex items-center gap-3">
<form action="{{ route('admin.sales.pass-codes.destroy', $code) }}" method="POST" class="flex-1" onsubmit="return confirm('{{ __('Are you sure you want to delete this pass code?') }}')">
@csrf
@method('DELETE')
<button type="submit" class="w-full flex items-center justify-center gap-2 py-3 rounded-xl bg-slate-50 dark:bg-slate-800 text-slate-500 dark:text-slate-400 font-bold text-xs hover:bg-rose-500 hover:text-white transition-all duration-300 border border-slate-200/50 dark:border-slate-700/50">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v2m3 4h.01" />
</svg>
{{ __('Delete') }}
</button>
</form>
</div>
</div>
@empty
<div class="col-span-full">
<x-empty-state :message="__('No pass codes found')" />
</div>
@endforelse
</div>
{{-- Pagination --}}
<div class="mt-8 border-t border-slate-100/50 dark:border-slate-800/50 pt-6">
{{ $passCodes->links('vendor.pagination.luxury') }}
</div>
</div>
</div> </div>
{{-- Create Modal --}} {{-- Create Modal --}}
@ -131,22 +205,22 @@
<div class="flex items-center justify-center min-h-screen px-4 pb-20 text-center sm:block sm:p-0"> <div class="flex items-center justify-center min-h-screen px-4 pb-20 text-center sm:block sm:p-0">
<div class="fixed inset-0 transition-opacity bg-slate-900/60 backdrop-blur-sm" @click="showCreateModal = false"></div> <div class="fixed inset-0 transition-opacity bg-slate-900/60 backdrop-blur-sm" @click="showCreateModal = false"></div>
<div class="inline-block overflow-hidden text-left align-bottom transition-all transform bg-white dark:bg-slate-900 rounded-3xl shadow-2xl sm:my-8 sm:align-middle sm:max-w-lg sm:w-full border border-slate-200/50 dark:border-slate-700/50 animate-luxury-in"> <div class="inline-block overflow-hidden text-left align-bottom transition-all transform bg-white dark:bg-slate-900 rounded-[2.5rem] shadow-2xl sm:my-8 sm:align-middle sm:max-w-lg sm:w-full border border-slate-200/50 dark:border-slate-700/50 animate-luxury-in">
<form action="{{ route('admin.sales.pass-codes.store') }}" method="POST"> <form action="{{ route('admin.sales.pass-codes.store') }}" method="POST">
@csrf @csrf
<div class="p-8"> <div class="p-10">
<div class="flex items-center justify-between mb-6"> <div class="flex items-center justify-between mb-8">
<h3 class="text-xl font-black text-slate-800 dark:text-white tracking-tight">{{ __('Create Pass Code') }}</h3> <h3 class="text-2xl font-black text-slate-800 dark:text-white tracking-tight font-display">{{ __('Create Pass Code') }}</h3>
<button type="button" @click="showCreateModal = false" class="text-slate-400 hover:text-slate-600 dark:hover:text-slate-200 transition-colors"> <button type="button" @click="showCreateModal = false" class="w-10 h-10 flex items-center justify-center rounded-full bg-slate-100 dark:bg-slate-800 text-slate-400 hover:text-slate-600 dark:hover:text-slate-200 transition-colors">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M6 18L18 6M6 6l12 12" /></svg> <svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M6 18L18 6M6 6l12 12" /></svg>
</button> </button>
</div> </div>
<div class="space-y-6"> <div class="space-y-8">
{{-- Machine Selection --}} {{-- Machine Selection --}}
<div> <div>
<label class="block text-xs font-black text-slate-500 dark:text-slate-400 uppercase tracking-widest mb-2">{{ __('Target Machine') }}</label> <label class="block text-[10px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-[0.2em] mb-3">{{ __('Target Machine') }}</label>
<select name="machine_id" x-model="selectedMachine" class="luxury-input w-full" required> <select name="machine_id" x-model="selectedMachine" class="luxury-input w-full py-3" required>
<option value="">{{ __('Please select a machine') }}</option> <option value="">{{ __('Please select a machine') }}</option>
@foreach($machines as $machine) @foreach($machines as $machine)
<option value="{{ $machine->id }}">{{ $machine->name }} ({{ $machine->serial_no }})</option> <option value="{{ $machine->id }}">{{ $machine->name }} ({{ $machine->serial_no }})</option>
@ -156,32 +230,42 @@
{{-- Name --}} {{-- Name --}}
<div> <div>
<label class="block text-xs font-black text-slate-500 dark:text-slate-400 uppercase tracking-widest mb-2">{{ __('Description / Name') }}</label> <label class="block text-[10px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-[0.2em] mb-3">{{ __('Description / Name') }}</label>
<input type="text" name="name" x-model="name" class="luxury-input w-full" placeholder="{{ __('e.g. Test Code for Maintenance') }}" required> <input type="text" name="name" x-model="name" class="luxury-input w-full py-3" placeholder="{{ __('e.g. Test Code for Maintenance') }}" required>
</div> </div>
{{-- Code --}} {{-- Code --}}
<div> <div>
<div class="flex items-center justify-between mb-2"> <div class="flex items-center justify-between mb-3">
<label class="block text-xs font-black text-slate-500 dark:text-slate-400 uppercase tracking-widest">{{ __('Pass Code (8 Digits)') }}</label> <label class="block text-[10px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-[0.2em]">{{ __('Pass Code (8 Digits)') }}</label>
<button type="button" @click="generateRandomCode()" class="text-xs font-bold text-cyan-500 hover:text-cyan-600 uppercase tracking-widest">{{ __('Regenerate') }}</button> <button type="button" @click="generateRandomCode()" class="text-xs font-bold text-cyan-500 hover:text-cyan-600 uppercase tracking-widest flex items-center gap-1">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" /></svg>
{{ __('Regenerate') }}
</button>
</div> </div>
<input type="text" name="custom_code" x-model="customCode" class="luxury-input w-full font-mono text-lg tracking-widest" maxlength="12" required> <input type="text" name="custom_code" x-model="customCode" class="luxury-input w-full py-4 font-mono text-2xl tracking-[0.3em] text-center text-cyan-600 dark:text-cyan-400 bg-cyan-50/30 dark:bg-cyan-500/5" maxlength="12" required>
</div> </div>
{{-- Validity --}} {{-- Validity --}}
<div> <div>
<label class="block text-xs font-black text-slate-500 dark:text-slate-400 uppercase tracking-widest mb-2">{{ __('Validity Period (Days)') }}</label> <label class="block text-[10px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-[0.2em] mb-3">{{ __('Validity Period (Days)') }}</label>
<input type="number" name="expires_days" x-model="expiresDays" class="luxury-input w-full" min="1" placeholder="{{ __('Leave empty for permanent') }}"> <div class="relative">
<input type="number" name="expires_days" x-model="expiresDays" class="luxury-input w-full py-3 pr-16" min="1" placeholder="{{ __('Permanent') }}">
<div class="absolute right-4 top-3 text-xs font-bold text-slate-400 uppercase tracking-widest">{{ __('Days') }}</div>
</div>
<p class="mt-3 text-[10px] text-slate-400 font-bold uppercase tracking-widest flex items-center gap-1.5">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
{{ __('Leave empty for permanent code') }}
</p>
</div> </div>
</div> </div>
</div> </div>
<div class="px-8 py-6 bg-slate-50/50 dark:bg-slate-800/50 flex justify-end gap-4 border-t border-slate-100 dark:border-slate-700"> <div class="px-10 py-8 bg-slate-50/50 dark:bg-slate-800/50 flex justify-end gap-4 border-t border-slate-100 dark:border-slate-800">
<button type="button" @click="showCreateModal = false" class="luxury-btn-secondary"> <button type="button" @click="showCreateModal = false" class="luxury-btn-secondary px-6">
{{ __('Cancel') }} {{ __('Cancel') }}
</button> </button>
<button type="submit" class="luxury-btn-primary px-8"> <button type="submit" class="luxury-btn-primary px-10">
{{ __('Create') }} {{ __('Create') }}
</button> </button>
</div> </div>

View File

@ -1,13 +1,14 @@
@extends('layouts.admin') @extends('layouts.admin')
@section('content') @section('content')
<div class="px-4 sm:px-6 lg:px-8 py-8 w-full max-w-9xl mx-auto" x-data="{ <div class="space-y-4 pb-20" x-data="{
showCreateModal: false, showCreateModal: false,
selectedMachine: '', selectedMachine: '',
selectedSlot: '', selectedSlot: '',
expiresHours: 24, expiresHours: 24,
slots: [], slots: [],
loadingSlots: false, loadingSlots: false,
isLoadingTable: false,
async fetchSlots() { async fetchSlots() {
if (!this.selectedMachine) { if (!this.selectedMachine) {
@ -24,129 +25,201 @@
} finally { } finally {
this.loadingSlots = false; this.loadingSlots = false;
} }
},
async fetchPage(url) {
if (!url || this.isLoadingTable) return;
this.isLoadingTable = true;
try {
const res = await fetch(url, { headers: { 'X-Requested-With': 'XMLHttpRequest' } });
const html = await res.text();
const doc = new DOMParser().parseFromString(html, 'text/html');
const newContent = doc.querySelector('#ajax-content-container');
if (newContent) {
document.querySelector('#ajax-content-container').innerHTML = newContent.innerHTML;
window.history.pushState({}, '', url);
if (window.HSStaticMethods?.autoInit) window.HSStaticMethods.autoInit();
}
} catch (e) {
console.error('AJAX Load Failed:', e);
} finally {
this.isLoadingTable = false;
}
} }
}"> }">
{{-- Page Header --}} {{-- Page Header --}}
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-8"> <x-page-header
<div> :title="__('Pickup Codes')"
<h1 class="text-2xl sm:text-3xl font-black text-slate-800 dark:text-white font-display tracking-tight">{{ __('Pickup Codes') }}</h1> :subtitle="__('Generate and manage one-time pickup codes for customers')"
<p class="text-sm font-bold text-slate-500 dark:text-slate-400 mt-1 uppercase tracking-widest">{{ __('Generate and manage one-time pickup codes for customers') }}</p> >
</div> <button @click="showCreateModal = true" class="luxury-btn-primary group flex items-center gap-2">
<button @click="showCreateModal = true" class="luxury-btn-primary group"> <svg class="w-5 h-5 group-hover:rotate-90 transition-transform duration-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<svg class="w-5 h-5 mr-2 group-hover:rotate-90 transition-transform duration-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M12 4v16m8-8H4" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M12 4v16m8-8H4" />
</svg> </svg>
{{ __('Generate New Code') }} <span class="text-sm sm:text-base">{{ __('Generate New Code') }}</span>
</button> </button>
</div> </x-page-header>
{{-- Search & Filters --}} {{-- Main Content Card --}}
<div class="luxury-card rounded-3xl p-6 mb-8"> <div class="luxury-card rounded-3xl p-8 animate-luxury-in relative overflow-hidden">
<form action="{{ route('admin.sales.pickup-codes') }}" method="GET" class="flex flex-col md:flex-row gap-4"> <x-luxury-spinner show="isLoadingTable" />
<div class="relative flex-1 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">
<circle cx="11" cy="11" r="8" /><path d="m21 21-4.3-4.3" />
</svg>
</span>
<input type="text" name="search" value="{{ request('search') }}" class="py-2.5 pl-12 pr-6 block w-full luxury-input" placeholder="{{ __('Search by code, machine name or serial...') }}">
</div>
<div class="w-full md:w-48">
<select name="status" class="luxury-input w-full" onchange="this.form.submit()">
<option value="">{{ __('All Status') }}</option>
<option value="active" {{ request('status') === 'active' ? 'selected' : '' }}>{{ __('Active') }}</option>
<option value="used" {{ request('status') === 'used' ? 'selected' : '' }}>{{ __('Used') }}</option>
<option value="expired" {{ request('status') === 'expired' ? 'selected' : '' }}>{{ __('Expired') }}</option>
<option value="cancelled" {{ request('status') === 'cancelled' ? 'selected' : '' }}>{{ __('Cancelled') }}</option>
</select>
</div>
<button type="submit" class="luxury-btn-secondary px-8">
{{ __('Filter') }}
</button>
</form>
</div>
{{-- Table --}} <div id="ajax-content-container" :class="{ 'opacity-30 pointer-events-none': isLoadingTable }">
<div class="luxury-card rounded-3xl overflow-hidden">
<div class="overflow-x-auto"> <x-search-bar
<table class="w-full text-left border-separate border-spacing-0"> :action="route('admin.sales.pickup-codes')"
<thead> :value="request('search')"
<tr class="bg-slate-50/50 dark:bg-slate-800/50"> :placeholder="__('Search by code, machine name or serial...')"
<th class="px-6 py-4 text-xs font-black uppercase tracking-wider text-slate-500 dark:text-slate-400 border-b border-slate-100 dark:border-slate-700">{{ __('Code') }}</th> >
<th class="px-6 py-4 text-xs font-black uppercase tracking-wider text-slate-500 dark:text-slate-400 border-b border-slate-100 dark:border-slate-700">{{ __('Machine / Slot') }}</th> <div class="w-full md:w-48">
<th class="px-6 py-4 text-xs font-black uppercase tracking-wider text-slate-500 dark:text-slate-400 border-b border-slate-100 dark:border-slate-700">{{ __('Expires At') }}</th> <select name="status" class="luxury-input w-full text-sm" @change="$el.closest('form').dispatchEvent(new Event('submit', { cancelable: true }))">
<th class="px-6 py-4 text-xs font-black uppercase tracking-wider text-slate-500 dark:text-slate-400 border-b border-slate-100 dark:border-slate-700">{{ __('Status') }}</th> <option value="">{{ __('All Status') }}</option>
<th class="px-6 py-4 text-xs font-black uppercase tracking-wider text-slate-500 dark:text-slate-400 border-b border-slate-100 dark:border-slate-700 text-right">{{ __('Actions') }}</th> <option value="active" {{ request('status') === 'active' ? 'selected' : '' }}>{{ __('Active') }}</option>
</tr> <option value="used" {{ request('status') === 'used' ? 'selected' : '' }}>{{ __('Used') }}</option>
</thead> <option value="expired" {{ request('status') === 'expired' ? 'selected' : '' }}>{{ __('Expired') }}</option>
<tbody class="divide-y divide-slate-100 dark:divide-slate-700"> <option value="cancelled" {{ request('status') === 'cancelled' ? 'selected' : '' }}>{{ __('Cancelled') }}</option>
@forelse ($pickupCodes as $code) </select>
<tr class="group hover:bg-slate-50/50 dark:hover:bg-slate-800/30 transition-colors"> </div>
<td class="px-6 py-4 whitespace-nowrap"> </x-search-bar>
<span class="text-lg font-black font-mono text-cyan-600 dark:text-cyan-400 tracking-tighter bg-cyan-50 dark:bg-cyan-500/10 px-3 py-1 rounded-lg border border-cyan-100 dark:border-cyan-500/20 shadow-sm">
{{ $code->code }} {{-- Table (Desktop) --}}
</span> <div class="hidden xl:block overflow-x-auto">
</td> <table class="w-full text-left border-separate border-spacing-0">
<td class="px-6 py-4"> <thead>
<div class="text-sm font-black text-slate-800 dark:text-white">{{ $code->machine->name }}</div> <tr class="bg-slate-50/50 dark:bg-slate-900/10">
<div class="text-xs font-bold text-slate-400 uppercase tracking-tighter">{{ __('Slot') }}: {{ $code->slot_no }}</div> <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">
</td> {{ __('Code') }}
<td class="px-6 py-4 whitespace-nowrap"> </th>
<div class="text-sm font-bold text-slate-600 dark:text-slate-300">{{ $code->expires_at->format('Y-m-d H:i') }}</div> <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">
<div class="text-xs font-medium text-slate-400">{{ $code->expires_at->diffForHumans() }}</div> {{ __('Machine / Slot') }}
</td> </th>
<td class="px-6 py-4 whitespace-nowrap"> <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">
@php {{ __('Expires At') }}
$statusClasses = [ </th>
'active' => 'bg-emerald-50 text-emerald-600 dark:bg-emerald-500/10 dark:text-emerald-400 border-emerald-100 dark:border-emerald-500/20', <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">
'used' => 'bg-blue-50 text-blue-600 dark:bg-blue-500/10 dark:text-blue-400 border-blue-100 dark:border-blue-500/20', {{ __('Status') }}
'expired' => 'bg-rose-50 text-rose-600 dark:bg-rose-500/10 dark:text-rose-400 border-rose-100 dark:border-rose-500/20', </th>
'cancelled' => 'bg-slate-100 text-slate-500 dark:bg-slate-700 dark:text-slate-400 border-slate-200 dark:border-slate-600', <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') }}
$displayStatus = $code->status === 'active' && $code->expires_at->isPast() ? 'expired' : $code->status; </th>
@endphp
<span class="px-3 py-1 text-xs font-black uppercase tracking-widest rounded-full border {{ $statusClasses[$displayStatus] ?? $statusClasses['cancelled'] }}">
{{ __($displayStatus) }}
</span>
</td>
<td class="px-6 py-4 whitespace-nowrap text-right">
<div class="flex items-center justify-end gap-2">
@if($code->status === 'active' && $code->expires_at->isFuture())
<form action="{{ route('admin.sales.pickup-codes.destroy', $code) }}" method="POST" onsubmit="return confirm('{{ __('Are you sure you want to cancel this code?') }}')">
@csrf
@method('DELETE')
<button type="submit" class="p-2 text-rose-500 hover:bg-rose-50 dark:hover:bg-rose-500/10 rounded-xl transition-colors" title="{{ __('Cancel Code') }}">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</form>
@endif
</div>
</td>
</tr> </tr>
@empty </thead>
<tr> <tbody class="divide-y divide-slate-100 dark:divide-slate-800/80">
<td colspan="5" class="px-6 py-12 text-center"> @forelse ($pickupCodes as $code)
<div class="flex flex-col items-center"> <tr class="group hover:bg-slate-50/50 dark:hover:bg-white/[0.02] transition-colors duration-200">
<div class="w-16 h-16 bg-slate-100 dark:bg-slate-800 rounded-full flex items-center justify-center mb-4"> <td class="px-6 py-6 whitespace-nowrap">
<svg class="w-8 h-8 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <span class="text-lg font-black font-mono text-cyan-600 dark:text-cyan-400 tracking-tighter bg-cyan-50 dark:bg-cyan-500/10 px-3 py-1 rounded-lg border border-cyan-100 dark:border-cyan-500/20 shadow-sm">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4" /> {{ $code->code }}
</svg> </span>
</td>
<td class="px-6 py-6">
<div class="text-base font-extrabold text-slate-800 dark:text-slate-100 tracking-tight">{{ $code->machine->name }}</div>
<div class="text-xs font-bold text-slate-400 uppercase tracking-widest">{{ __('Slot') }}: {{ $code->slot_no }}</div>
</td>
<td class="px-6 py-6 whitespace-nowrap">
<div class="text-sm font-black text-slate-600 dark:text-slate-300 font-mono tracking-widest">{{ $code->expires_at->format('Y-m-d H:i') }}</div>
<div class="text-xs font-bold text-slate-400 mt-0.5">{{ $code->expires_at->diffForHumans() }}</div>
</td>
<td class="px-6 py-6 whitespace-nowrap">
@php
$displayStatus = $code->status === 'active' && $code->expires_at->isPast() ? 'expired' : $code->status;
@endphp
<x-status-badge :status="$displayStatus" />
</td>
<td class="px-6 py-6 whitespace-nowrap text-right">
<div class="flex items-center justify-end gap-2">
@if($code->status === 'active' && $code->expires_at->isFuture())
<form action="{{ route('admin.sales.pickup-codes.destroy', $code) }}" method="POST" onsubmit="return confirm('{{ __('Are you sure you want to cancel this code?') }}')">
@csrf
@method('DELETE')
<button type="submit" class="p-2 text-rose-500 hover:bg-rose-500/10 rounded-xl transition-all duration-200" title="{{ __('Cancel Code') }}">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</form>
@endif
</div> </div>
<p class="text-slate-500 dark:text-slate-400 font-bold uppercase tracking-widest text-xs">{{ __('No pickup codes found') }}</p> </td>
</div> </tr>
</td> @empty
</tr> <x-empty-state mode="table" :colspan="5" :message="__('No pickup codes found')" />
@endforelse @endforelse
</tbody> </tbody>
</table> </table>
</div>
@if ($pickupCodes->hasPages())
<div class="px-6 py-4 border-t border-slate-100 dark:border-slate-700">
{{ $pickupCodes->links() }}
</div> </div>
@endif
{{-- Card Grid (Mobile) --}}
<div class="xl:hidden grid grid-cols-1 md:grid-cols-2 gap-6">
@forelse ($pickupCodes as $code)
<div class="luxury-card p-6 rounded-[2rem] border border-slate-100 dark:border-slate-800 bg-white/50 dark:bg-slate-900/50 transition-all duration-300 group">
{{-- Card Header --}}
<div class="flex items-start justify-between gap-4 mb-6">
<div class="flex items-center gap-4 min-w-0">
<div class="w-14 h-14 rounded-2xl 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 transition-all duration-300 overflow-hidden shadow-sm shrink-0">
<svg class="w-7 h-7" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4" />
</svg>
</div>
<div class="min-w-0">
<h3 class="text-base font-extrabold text-slate-800 dark:text-slate-100 truncate hover:text-cyan-600 dark:hover:text-cyan-400 transition-colors tracking-tight">
{{ $code->code }}
</h3>
<p class="text-xs font-mono font-bold text-slate-400 dark:text-slate-500 uppercase tracking-widest truncate">
{{ $code->machine->name }}
</p>
</div>
</div>
@php
$displayStatus = $code->status === 'active' && $code->expires_at->isPast() ? 'expired' : $code->status;
@endphp
<x-status-badge :status="$displayStatus" size="sm" />
</div>
{{-- Info Grid --}}
<div class="grid grid-cols-2 gap-y-4 mb-6 border-y border-slate-100 dark:border-slate-800/50 py-4">
<div>
<p class="text-[10px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-widest mb-1">{{ __('Slot') }}</p>
<p class="text-sm font-bold text-slate-700 dark:text-slate-300">{{ $code->slot_no }}</p>
</div>
<div>
<p class="text-[10px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-widest mb-1">{{ __('Expires At') }}</p>
<p class="text-sm font-bold text-slate-700 dark:text-slate-300 font-mono tracking-tighter">{{ $code->expires_at->format('Y-m-d H:i') }}</p>
</div>
</div>
{{-- Action Buttons --}}
<div class="flex items-center gap-3">
@if($code->status === 'active' && $code->expires_at->isFuture())
<form action="{{ route('admin.sales.pickup-codes.destroy', $code) }}" method="POST" class="flex-1" onsubmit="return confirm('{{ __('Are you sure you want to cancel this code?') }}')">
@csrf
@method('DELETE')
<button type="submit" class="w-full flex items-center justify-center gap-2 py-3 rounded-xl bg-slate-50 dark:bg-slate-800 text-slate-500 dark:text-slate-400 font-bold text-xs hover:bg-rose-500 hover:text-white transition-all duration-300 border border-slate-200/50 dark:border-slate-700/50">
<svg class="w-3.5 h-3.5" 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>
{{ __('Cancel') }}
</button>
</form>
@else
<div class="flex-1 py-3 text-center text-slate-400 text-xs font-bold uppercase tracking-widest bg-slate-50/50 dark:bg-slate-800/30 rounded-xl">
{{ __('No Actions') }}
</div>
@endif
</div>
</div>
@empty
<div class="col-span-full">
<x-empty-state :message="__('No pickup codes found')" />
</div>
@endforelse
</div>
{{-- Pagination --}}
<div class="mt-8 border-t border-slate-100/50 dark:border-slate-800/50 pt-6">
{{ $pickupCodes->links('vendor.pagination.luxury') }}
</div>
</div>
</div> </div>
{{-- Create Modal --}} {{-- Create Modal --}}
@ -162,22 +235,22 @@
<div class="flex items-center justify-center min-h-screen px-4 pb-20 text-center sm:block sm:p-0"> <div class="flex items-center justify-center min-h-screen px-4 pb-20 text-center sm:block sm:p-0">
<div class="fixed inset-0 transition-opacity bg-slate-900/60 backdrop-blur-sm" @click="showCreateModal = false"></div> <div class="fixed inset-0 transition-opacity bg-slate-900/60 backdrop-blur-sm" @click="showCreateModal = false"></div>
<div class="inline-block overflow-hidden text-left align-bottom transition-all transform bg-white dark:bg-slate-900 rounded-3xl shadow-2xl sm:my-8 sm:align-middle sm:max-w-lg sm:w-full border border-slate-200/50 dark:border-slate-700/50 animate-luxury-in"> <div class="inline-block overflow-hidden text-left align-bottom transition-all transform bg-white dark:bg-slate-900 rounded-[2.5rem] shadow-2xl sm:my-8 sm:align-middle sm:max-w-lg sm:w-full border border-slate-200/50 dark:border-slate-700/50 animate-luxury-in">
<form action="{{ route('admin.sales.pickup-codes.store') }}" method="POST"> <form action="{{ route('admin.sales.pickup-codes.store') }}" method="POST">
@csrf @csrf
<div class="p-8"> <div class="p-10">
<div class="flex items-center justify-between mb-6"> <div class="flex items-center justify-between mb-8">
<h3 class="text-xl font-black text-slate-800 dark:text-white tracking-tight">{{ __('Generate Pickup Code') }}</h3> <h3 class="text-2xl font-black text-slate-800 dark:text-white tracking-tight font-display">{{ __('Generate Pickup Code') }}</h3>
<button type="button" @click="showCreateModal = false" class="text-slate-400 hover:text-slate-600 dark:hover:text-slate-200 transition-colors"> <button type="button" @click="showCreateModal = false" class="w-10 h-10 flex items-center justify-center rounded-full bg-slate-100 dark:bg-slate-800 text-slate-400 hover:text-slate-600 dark:hover:text-slate-200 transition-colors">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M6 18L18 6M6 6l12 12" /></svg> <svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M6 18L18 6M6 6l12 12" /></svg>
</button> </button>
</div> </div>
<div class="space-y-6"> <div class="space-y-8">
{{-- Machine Selection --}} {{-- Machine Selection --}}
<div> <div>
<label class="block text-xs font-black text-slate-500 dark:text-slate-400 uppercase tracking-widest mb-2">{{ __('Select Machine') }}</label> <label class="block text-[10px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-[0.2em] mb-3">{{ __('Select Machine') }}</label>
<select name="machine_id" x-model="selectedMachine" @change="fetchSlots()" class="luxury-input w-full" required> <select name="machine_id" x-model="selectedMachine" @change="fetchSlots()" class="luxury-input w-full py-3" required>
<option value="">{{ __('Please select a machine') }}</option> <option value="">{{ __('Please select a machine') }}</option>
@foreach($machines as $machine) @foreach($machines as $machine)
<option value="{{ $machine->id }}">{{ $machine->name }} ({{ $machine->serial_no }})</option> <option value="{{ $machine->id }}">{{ $machine->name }} ({{ $machine->serial_no }})</option>
@ -187,15 +260,15 @@
{{-- Slot Selection --}} {{-- Slot Selection --}}
<div> <div>
<label class="block text-xs font-black text-slate-500 dark:text-slate-400 uppercase tracking-widest mb-2">{{ __('Select Slot') }}</label> <label class="block text-[10px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-[0.2em] mb-3">{{ __('Select Slot') }}</label>
<div class="relative"> <div class="relative group">
<select name="slot_no" x-model="selectedSlot" class="luxury-input w-full" :disabled="!selectedMachine || loadingSlots" required> <select name="slot_no" x-model="selectedSlot" class="luxury-input w-full py-3" :disabled="!selectedMachine || loadingSlots" required>
<option value="">{{ __('Select Slot') }}</option> <option value="">{{ __('Select Slot') }}</option>
<template x-for="slot in slots" :key="slot.slot_no"> <template x-for="slot in slots" :key="slot.slot_no">
<option :value="slot.slot_no" x-text="`${slot.slot_no} - ${slot.product_name || 'Empty'}`"></option> <option :value="slot.slot_no" x-text="`${slot.slot_no} - ${slot.product_name || 'Empty'}`"></option>
</template> </template>
</select> </select>
<div x-show="loadingSlots" class="absolute right-10 top-2.5"> <div x-show="loadingSlots" class="absolute right-4 top-3.5">
<svg class="animate-spin h-5 w-5 text-cyan-500" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path></svg> <svg class="animate-spin h-5 w-5 text-cyan-500" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path></svg>
</div> </div>
</div> </div>
@ -203,18 +276,24 @@
{{-- Validity --}} {{-- Validity --}}
<div> <div>
<label class="block text-xs font-black text-slate-500 dark:text-slate-400 uppercase tracking-widest mb-2">{{ __('Validity Period (Hours)') }}</label> <label class="block text-[10px] font-black text-slate-400 dark:text-slate-500 uppercase tracking-[0.2em] mb-3">{{ __('Validity Period (Hours)') }}</label>
<input type="number" name="expires_hours" x-model="expiresHours" class="luxury-input w-full" min="1" max="720" required> <div class="relative">
<p class="mt-1 text-xs text-slate-400 font-medium">{{ __('Default is 24 hours. Max is 720 hours (30 days).') }}</p> <input type="number" name="expires_hours" x-model="expiresHours" class="luxury-input w-full py-3 pr-16" min="1" max="720" required>
<div class="absolute right-4 top-3 text-xs font-bold text-slate-400 uppercase tracking-widest">{{ __('Hrs') }}</div>
</div>
<p class="mt-3 text-[10px] text-slate-400 font-bold uppercase tracking-widest flex items-center gap-1.5">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
{{ __('Max 720 hours (30 days)') }}
</p>
</div> </div>
</div> </div>
</div> </div>
<div class="px-8 py-6 bg-slate-50/50 dark:bg-slate-800/50 flex justify-end gap-4 border-t border-slate-100 dark:border-slate-700"> <div class="px-10 py-8 bg-slate-50/50 dark:bg-slate-800/50 flex justify-end gap-4 border-t border-slate-100 dark:border-slate-800">
<button type="button" @click="showCreateModal = false" class="luxury-btn-secondary"> <button type="button" @click="showCreateModal = false" class="luxury-btn-secondary px-6">
{{ __('Cancel') }} {{ __('Cancel') }}
</button> </button>
<button type="submit" class="luxury-btn-primary px-8" :disabled="!selectedSlot"> <button type="submit" class="luxury-btn-primary px-10" :disabled="!selectedSlot">
{{ __('Generate') }} {{ __('Generate') }}
</button> </button>
</div> </div>

View File

@ -383,7 +383,7 @@
@endcan @endcan
@if(auth()->user()->isSystemAdmin()) @if(auth()->user()->isSystemAdmin())
@can('menu.basic-settings') @can('menu.basic')
{{-- 14.5. 基本設定 --}} {{-- 14.5. 基本設定 --}}
<li x-data="{ open: localStorage.getItem('menu_basic_settings') === 'true' || {{ request()->routeIs('admin.basic-settings.*') ? 'true' : 'false' }} }"> <li x-data="{ open: localStorage.getItem('menu_basic_settings') === 'true' || {{ request()->routeIs('admin.basic-settings.*') ? 'true' : 'false' }} }">
<button type="button" @click="if(sidebarCollapsed) { sidebarCollapsed = false; open = true; } else { open = !open; } localStorage.setItem('menu_basic_settings', open)" class="luxury-nav-item w-full text-start group"> <button type="button" @click="if(sidebarCollapsed) { sidebarCollapsed = false; open = true; } else { open = !open; } localStorage.setItem('menu_basic_settings', open)" class="luxury-nav-item w-full text-start group">

View File

@ -10,8 +10,9 @@
$currentLimit = $paginator->perPage(); $currentLimit = $paginator->perPage();
$limits = [10, 25, 50, 100]; $limits = [10, 25, 50, 100];
$event = $ajax_navigate_event ?? 'ajax:navigate'; $event = $ajax_navigate_event ?? 'ajax:navigate';
$perPageKey = $per_page_param ?? 'per_page';
@endphp @endphp
<select onchange="{ const params = new URLSearchParams(window.location.search); params.set('per_page', this.value); params.delete('page'); const url = window.location.pathname + '?' + params.toString(); const evt = new CustomEvent('{{ $event }}', { detail: { url: url }, bubbles: true, cancelable: true }); if (this.dispatchEvent(evt)) { window.location.href = url; } }" <select onchange="{ const params = new URLSearchParams(window.location.search); params.set('{{ $perPageKey }}', this.value); params.delete('page'); const url = window.location.pathname + '?' + params.toString(); const evt = new CustomEvent('{{ $event }}', { detail: { url: url }, bubbles: true, cancelable: true }); if (this.dispatchEvent(evt)) { window.location.href = url; } }"
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"> 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) @foreach($limits as $l)
<option value="{{ $l }}" {{ $currentLimit == $l ? 'selected' : '' }}>{{ $l }}</option> <option value="{{ $l }}" {{ $currentLimit == $l ? 'selected' : '' }}>{{ $l }}</option>