[FEAT] 優化帳號管理授權顯示邏輯與 UI 樣式一致性
All checks were successful
star-cloud-deploy-demo / deploy-demo (push) Successful in 59s

This commit is contained in:
2026-03-23 17:16:26 +08:00
parent 72812f9b0b
commit 38770b080b
26 changed files with 1265 additions and 444 deletions

View File

@@ -15,7 +15,7 @@ Skills 位於 `.agents/skills/`,採漸進式揭露以節省 Token。
| 觸發詞 / 情境 | 對應 Skill | 路徑 |
|---|---|---|
| 機台通訊, IoT, 日誌上報, Log Ingestion, 異步隊列, Queue, Heartbeat, 心跳發報 | **IoT 通訊與高併發處理規範** | `.agents/skills/iot-communication/SKILL.md` |
| 介面, UI, 佈局, CSS, Tailwind, 奢華, 深色模式, Light Mode, Dark Mode, Blade, 樣式, 間距, 陰影, 動畫 | **極簡奢華風 UI 實作規範** | `.agents/skills/ui-minimal-luxury/SKILL.md` |
| 介面, UI, 設計, 佈局, CSS, Tailwind, 奢華, 深色模式, Light Mode, Dark Mode, Blade, 樣式, 間距, 陰影, 動畫, 畫面, 頁面 | **極簡奢華風 UI 實作規範** | `.agents/skills/ui-minimal-luxury/SKILL.md` |
| 查詢、撈資料、Query、Controller、下拉選單、Eloquent、N+1、`->get()`、select、交易、Transaction、Bulk、分頁、索引 | **資料庫與 ORM 最佳實踐規範** | `/home/mama/.gemini/antigravity/global_skills/database-best-practices/SKILL.md` |
| RBAC, 權限, 角色, 租戶, Tenant, Company, Access Control, 多租戶, 權限控管 | **多租戶與權限架構實作規範** | `.agents/rules/rbac-rules.md` |

View File

@@ -178,6 +178,25 @@ description: 定義 Star Cloud 管理後台的「極簡奢華風」設計規範
<option value="1">啟用</option>
<option value="0">禁用</option>
</select>
### 搜尋式下拉選單 (Searchable Select) - 【進階推薦】
- **組件**: `<x-searchable-select />`
- **適用場景**: 選項大於 10 筆或具備層級關聯的篩選器(如:所屬單位、機台編號)。
- **奢華特徵**:
- **動態旋轉箭頭**: 透過 `::after` 偽元素實作,選單展開時箭頭執行 `300ms` 的 180 度旋轉動畫。
- **即時過濾**: 輸入關鍵字即時隱藏不匹配項。
- **選取標示**: 選取的項目右側帶有青色 (`Cyan`) 的勾選小圖標。
- **全部選項修復 (Space Fix)**: 若用於篩選(如公司篩選),組件內部已實作「空格佔位符」機制。若選單中的「全部」選項在選取後消失,請確保該選項的值為單個空格 (`value=" "`)。這能繞過 Preline 對空標記的隱藏邏輯,並同步觸發 Laravel 的 `blank()` 判定。
```html
<x-searchable-select
name="company_id"
:options="$companies"
:selected="request('company_id')"
:placeholder="__('All Companies')"
onchange="this.form.submit()"
/>
```
```
## 8. 編輯與詳情頁規範 (Detail & Edit Views)

View File

@@ -45,7 +45,7 @@ class MachineSettingController extends AdminController
// 3. 基礎下拉資料 (用於新增/編輯機台的彈窗)
$models = MachineModel::select('id', 'name')->get();
$paymentConfigs = PaymentConfig::select('id', 'name')->get();
$companies = \App\Models\System\Company::select('id', 'name')->get();
$companies = \App\Models\System\Company::select('id', 'name', 'code')->get();
return view('admin.basic-settings.machines.index', compact(
'machines',
@@ -101,7 +101,7 @@ class MachineSettingController extends AdminController
{
$models = MachineModel::select('id', 'name')->get();
$paymentConfigs = PaymentConfig::select('id', 'name')->get();
$companies = \App\Models\System\Company::select('id', 'name')->get();
$companies = \App\Models\System\Company::select('id', 'name', 'code')->get();
return view('admin.basic-settings.machines.edit', compact('machine', 'models', 'paymentConfigs', 'companies'));
}
@@ -138,6 +138,13 @@ class MachineSettingController extends AdminController
'location' => 'nullable|string|max:255',
]);
// 僅限系統管理員可修改公司
if (auth()->user()->isSystemAdmin()) {
$companyRule = ['company_id' => 'nullable|exists:companies,id'];
$companyData = $request->validate($companyRule);
$validated = array_merge($validated, $companyData);
}
Log::info('Machine Update Validated Data', ['data' => $validated]);
} catch (\Illuminate\Validation\ValidationException $e) {
Log::error('Machine Update Validation Failed', ['errors' => $e->errors()]);

View File

@@ -38,7 +38,7 @@ class PaymentConfigController extends AdminController
*/
public function create(): View
{
$companies = \App\Models\System\Company::select('id', 'name')->get();
$companies = \App\Models\System\Company::select('id', 'name', 'code')->get();
return view('admin.basic-settings.payment-configs.create', compact('companies'));
}

View File

@@ -67,12 +67,68 @@ class MachineController extends AdminController
return view('admin.machines.logs', compact('logs', 'machines'));
}
/**
* 機台權限設定 (開發中)
* AJAX: 取得特定帳號的機台分配狀態
*/
public function permissions(Request $request): View
public function getAccountMachines(\App\Models\System\User $user)
{
return view('admin.machines.index', ['machines' => Machine::paginate(1)]); // Placeholder
$currentUser = auth()->user();
// 安全檢查:只能操作自己公司的帳號(除非是系統管理員)
if (!$currentUser->isSystemAdmin() && $user->company_id !== $currentUser->company_id) {
return response()->json(['error' => 'Unauthorized'], 403);
}
// 取得該公司所有機台 (限定 company_id 以實作資料隔離)
$machines = Machine::where('company_id', $user->company_id)
->get(['id', 'name', 'serial_no']);
$assignedIds = $user->machines()->pluck('machines.id')->toArray();
return response()->json([
'user' => $user,
'machines' => $machines,
'assigned_ids' => $assignedIds
]);
}
/**
* AJAX: 儲存特定帳號的機台分配
*/
public function syncAccountMachines(Request $request, \App\Models\System\User $user)
{
$currentUser = auth()->user();
// 安全檢查
if (!$currentUser->isSystemAdmin() && $user->company_id !== $currentUser->company_id) {
return response()->json(['error' => 'Unauthorized'], 403);
}
$request->validate([
'machine_ids' => 'nullable|array',
'machine_ids.*' => 'exists:machines,id'
]);
// 加固驗證:確保所有機台 ID 都屬於該使用者的公司
if ($request->has('machine_ids')) {
$machineIds = array_unique($request->machine_ids);
$validCount = Machine::where('company_id', $user->company_id)
->whereIn('id', $machineIds)
->count();
if ($validCount !== count($machineIds)) {
return response()->json(['error' => 'Invalid machine IDs provided.'], 422);
}
}
$user->machines()->sync($request->machine_ids ?? []);
return response()->json([
'success' => true,
'message' => __('Permissions updated successfully.'),
'assigned_machines' => $user->machines()->select('machines.id', 'machines.name', 'machines.serial_no')->get()
]);
}
/**

View File

@@ -248,7 +248,7 @@ class PermissionController extends Controller
// 帳號管理
public function accounts(Request $request)
{
$query = \App\Models\System\User::query()->with(['company', 'roles']);
$query = \App\Models\System\User::query()->with(['company', 'roles', 'machines']);
// 租戶隔離:如果不是系統管理員,則只看自己公司的成員
if (!auth()->user()->isSystemAdmin()) {

View File

@@ -12,6 +12,26 @@ class Machine extends Model
use HasFactory, TenantScoped;
use \Illuminate\Database\Eloquent\SoftDeletes;
protected static function booted()
{
// 權限隔離:一般帳號登入時只能看到自己被分配的機台
static::addGlobalScope('machine_access', function (\Illuminate\Database\Eloquent\Builder $builder) {
$user = auth()->user();
// 如果是在 Console、或是系統管理員、或是租戶的「管理員」角色則不限制 (可看該公司所有機台)
if (app()->runningInConsole() || !$user || $user->isSystemAdmin() || $user->hasRole('管理員') || $user->hasRole('super-admin')) {
return;
}
// 一般租戶帳號:限制只能看自己擁有的機台
$builder->whereExists(function ($query) use ($user) {
$query->select(\Illuminate\Support\Facades\DB::raw(1))
->from('machine_user')
->whereColumn('machine_user.machine_id', 'machines.id')
->where('machine_user.user_id', $user->id);
});
});
}
protected $fillable = [
'company_id',
'name',
@@ -101,4 +121,8 @@ class Machine extends Model
return $this->belongsTo(\App\Models\System\User::class, 'updater_id');
}
public function users()
{
return $this->belongsToMany(\App\Models\System\User::class);
}
}

View File

@@ -69,6 +69,14 @@ class User extends Authenticatable
return $this->belongsTo(Company::class);
}
/**
* Get the machines assigned to the user.
*/
public function machines()
{
return $this->belongsToMany(\App\Models\Machine\Machine::class);
}
/**
* Check if the user is a system administrator.
*/

View File

@@ -17,6 +17,7 @@ class MachineFactory extends Factory
'status' => fake()->randomElement(['online', 'offline', 'error']),
'temperature' => fake()->randomFloat(2, 2, 10),
'firmware_version' => 'v' . fake()->randomElement(['1.0.0', '1.1.2', '2.0.1']),
'serial_no' => 'SN-' . strtoupper(fake()->unique()->bothify('??###?')),
'last_heartbeat_at' => fake()->dateTimeBetween('-1 day', 'now'),
];
}

View File

@@ -0,0 +1,31 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('machine_user', function (Blueprint $table) {
$table->id();
$table->foreignId('machine_id')->constrained()->onDelete('cascade');
$table->foreignId('user_id')->constrained()->onDelete('cascade');
$table->timestamps();
$table->unique(['machine_id', 'user_id']); // Ensure uniqueness
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('machine_user');
}
};

View File

@@ -505,5 +505,15 @@
"Role Identification": "Role Identification",
"LEVEL TYPE": "LEVEL TYPE",
"Affiliated Unit": "Affiliated Unit",
"System Official": "System Official"
"System Official": "System Official",
"Full Access": "Full Access",
"System Default": "System Default",
"Authorized Machines": "Authorized Machines",
"Assign": "Assign",
"No machines available": "No machines available",
"Selected": "Selected",
"Failed to fetch machine data.": "Failed to fetch machine data.",
"Failed to save permissions.": "Failed to save permissions.",
"An error occurred while saving.": "An error occurred while saving.",
"Loading machines...": "Loading machines..."
}

View File

@@ -20,6 +20,17 @@
"admin": "管理員",
"Admin display name": "管理員顯示名稱",
"Admin Name": "管理員姓名",
"Account Name": "帳號姓名",
"Account": "帳號",
"Account:": "帳號:",
"Manage Account Access": "管理帳號存取",
"Assigned Machines": "授權機台",
"No machines assigned": "未分配機台",
"Available Machines": "可供分配的機台",
"Loading machines...": "正在載入機台...",
"No machines available in this company.": "此客戶目前沒有可供分配的機台。",
"Saving...": "儲存中...",
"Save Permissions": "儲存權限",
"Admin Sellable Products": "管理者可賣",
"Administrator": "管理員",
"Advertisement Management": "廣告管理",
@@ -230,7 +241,7 @@
"Machine Model Settings": "機台型號設定",
"Machine model updated successfully.": "機台型號已成功更新。",
"Machine Name": "機台名稱",
"Machine Permissions": "機台權限",
"Machine Permissions": "授權機台",
"Machine Reports": "機台報表",
"Machine Restart": "機台重啟",
"Machine Settings": "機台設定",
@@ -524,5 +535,26 @@
"Role Identification": "角色識別資訊",
"LEVEL TYPE": "層級類型",
"Affiliated Unit": "所屬單位",
"System Official": "系統層"
"System Official": "系統層",
"Other Permissions": "其他權限",
"General permissions not linked to a specific menu.": "未連結到特定選單的一般權限。",
"Assign Machines": "分配機台",
"data-config.sub-accounts": "子帳號管理",
"data-config.sub-account-roles": "子帳號角色",
"basic.machines": "機台設定",
"basic.payment-configs": "客戶金流設定",
"permissions.companies": "客戶管理",
"permissions.accounts": "帳號管理",
"permissions.roles": "角色權限管理",
"Edit Sub Account Role": "編輯子帳號角色",
"New Sub Account Role": "新增子帳號角色",
"Full Access": "全機台授權",
"System Default": "系統預設",
"Authorized Machines": "授權機台",
"Assign": "分配所屬機台",
"No machines available": "目前沒有可供分配的機台",
"Selected": "已選擇",
"Failed to fetch machine data.": "無法取得機台資料。",
"Failed to save permissions.": "無法儲存權限設定。",
"An error occurred while saving.": "儲存時發生錯誤。"
}

View File

@@ -265,4 +265,48 @@
background-repeat: no-repeat;
background-size: 1.25em 1.25em;
}
/* Preline Searchable Select Customizations */
.hs-select-toggle.luxury-select-toggle {
@apply luxury-input pr-10 cursor-pointer text-start flex items-center justify-between relative;
}
.hs-select-toggle.luxury-select-toggle::after {
content: "";
@apply absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 transition-transform duration-300 pointer-events-none;
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' stroke='%2394a3b8' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3e%3cpath d='m6 9 6 6 6-6'/%3e%3c/svg%3e");
background-repeat: no-repeat;
background-size: contain;
}
/* 當選單開啟時旋轉箭頭 */
.hs-select.active .hs-select-toggle::after,
.hs-select-toggle.active::after,
.hs-select-toggle[aria-expanded="true"]::after {
@apply rotate-180;
}
.hs-select-menu {
@apply mt-2 max-h-72 p-2 space-y-0.5 z-50 bg-white border border-slate-200 rounded-2xl shadow-2xl overflow-y-auto;
@apply dark:bg-slate-900 dark:border-slate-800;
backdrop-filter: blur(12px);
}
.hs-select-option {
@apply py-2.5 px-4 w-full text-sm font-bold text-slate-700 cursor-pointer hover:bg-slate-50 rounded-xl transition-colors;
@apply dark:text-slate-200 dark:hover:bg-slate-800;
}
.hs-selected.hs-select-option {
@apply bg-cyan-50 text-cyan-600;
@apply dark:bg-cyan-500/10 dark:text-cyan-400;
}
.hs-select-search-wrapper {
@apply p-2 sticky top-0 bg-white/80 dark:bg-slate-900/80 backdrop-blur-md z-10 mb-1 border-b border-slate-100 dark:border-slate-800;
}
.hs-select-search-input {
@apply luxury-input py-2 px-3 text-xs border-slate-100 dark:border-slate-800;
}
}

View File

@@ -62,7 +62,7 @@
<!-- Left: Basic info & Hardware -->
<div class="lg:col-span-2 space-y-6">
<!-- Basic Information -->
<div class="luxury-card rounded-3xl p-7 animate-luxury-in">
<div class="luxury-card rounded-3xl p-7 animate-luxury-in relative z-20">
<div class="flex items-center gap-3 mb-8">
<div class="w-10 h-10 rounded-xl bg-emerald-500/10 flex items-center justify-center text-emerald-500">
<svg class="w-5 h-5 stroke-[2.5]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -87,17 +87,39 @@
</div>
<div>
<label class="block text-xs font-bold text-slate-400 uppercase tracking-[0.15em] mb-2">{{ __('Machine Model') }}</label>
<select name="machine_model_id" class="luxury-select w-full" required>
<x-searchable-select
name="machine_model_id"
:selected="old('machine_model_id', $machine->machine_model_id)"
required
>
@foreach($models as $model)
<option value="{{ $model->id }}" {{ old('machine_model_id', $machine->machine_model_id) == $model->id ? 'selected' : '' }}>{{ $model->name }}</option>
@endforeach
</select>
</x-searchable-select>
</div>
@if(auth()->user()->isSystemAdmin())
<div>
<label class="block text-xs font-bold text-slate-400 uppercase tracking-[0.15em] mb-2">{{ __('Company') }}</label>
<x-searchable-select
name="company_id"
:selected="old('company_id', $machine->company_id)"
:placeholder="__('No Company')"
>
@foreach($companies as $company)
<option value="{{ $company->id }}" {{ old('company_id', $machine->company_id) == $company->id ? 'selected' : '' }}
data-title="{{ $company->name }}{{ $company->code ? ' (' . $company->code . ')' : '' }}">
{{ $company->name }}{{ $company->code ? ' (' . $company->code . ')' : '' }}
</option>
@endforeach
</x-searchable-select>
@error('company_id') <p class="mt-1 text-xs text-rose-500 font-bold uppercase tracking-wider">{{ $message }}</p> @enderror
</div>
@endif
</div>
</div>
<!-- Operational Parameters -->
<div class="luxury-card rounded-3xl p-8 animate-luxury-in" style="animation-delay: 50ms">
<div class="luxury-card rounded-3xl p-8 animate-luxury-in relative z-10" style="animation-delay: 50ms">
<div class="flex items-center gap-3 mb-8">
<div class="w-10 h-10 rounded-xl bg-amber-500/10 flex items-center justify-center text-amber-500">
<svg class="w-5 h-5 stroke-[2.5]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -176,7 +198,7 @@
<!-- Right: System & Payment -->
<div class="space-y-8">
<div class="luxury-card rounded-3xl p-8 animate-luxury-in" style="animation-delay: 200ms">
<div class="luxury-card rounded-3xl p-8 animate-luxury-in relative z-20" style="animation-delay: 200ms">
<div class="flex items-center gap-3 mb-8">
<div class="w-10 h-10 rounded-xl bg-emerald-500/10 flex items-center justify-center text-emerald-500">
<svg class="w-5 h-5 stroke-[2.5]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -189,26 +211,34 @@
<div class="space-y-6">
<div>
<label class="block text-xs font-bold text-slate-400 uppercase tracking-[0.15em] mb-2">{{ __('Payment Config') }}</label>
<select name="payment_config_id" class="luxury-select w-full">
<option value="">{{ __('Not Used') }}</option>
<x-searchable-select
name="payment_config_id"
:selected="old('payment_config_id', $machine->payment_config_id)"
:placeholder="__('Not Used')"
:hasSearch="false"
>
@foreach($paymentConfigs as $config)
<option value="{{ $config->id }}" {{ $machine->payment_config_id == $config->id ? 'selected' : '' }}>{{ $config->name }}</option>
@endforeach
</select>
</x-searchable-select>
</div>
<div>
<label class="block text-xs font-bold text-slate-400 uppercase tracking-[0.15em] mb-2">{{ __('Invoice Status') }}</label>
<select name="invoice_status" class="luxury-select w-full">
<x-searchable-select
name="invoice_status"
:selected="old('invoice_status', $machine->invoice_status)"
:hasSearch="false"
>
<option value="0" {{ $machine->invoice_status == 0 ? 'selected' : '' }}>{{ __('No Invoice') }}</option>
<option value="1" {{ $machine->invoice_status == 1 ? 'selected' : '' }}>{{ __('Default Donate') }}</option>
<option value="2" {{ $machine->invoice_status == 2 ? 'selected' : '' }}>{{ __('Default Not Donate') }}</option>
</select>
</x-searchable-select>
</div>
</div>
</div>
<div class="luxury-card rounded-3xl p-8 animate-luxury-in" style="animation-delay: 300ms">
<div class="luxury-card rounded-3xl p-8 animate-luxury-in relative z-10" style="animation-delay: 300ms">
<div class="flex items-center gap-3 mb-8">
<div class="w-10 h-10 rounded-xl bg-indigo-500/10 flex items-center justify-center text-indigo-500">
<svg class="w-5 h-5 stroke-[2.5]" fill="none" stroke="currentColor" viewBox="0 0 24 24">

View File

@@ -391,16 +391,17 @@
<input type="text" name="serial_no" required class="luxury-input w-full"
placeholder="{{ __('Enter serial number') }}">
</div>
<div>
<div class="relative z-20">
<label
class="block text-[11px] font-black text-slate-400 uppercase tracking-[0.2em] mb-2">{{
__('Owner') }}</label>
<select name="company_id" required class="luxury-select w-full">
<option value="">{{ __('Select Owner') }}</option>
<x-searchable-select name="company_id" required :placeholder="__('Select Owner')">
@foreach($companies as $company)
<option value="{{ $company->id }}">{{ $company->name }}</option>
<option value="{{ $company->id }}" data-title="{{ $company->name }}{{ $company->code ? ' (' . $company->code . ')' : '' }}">
{{ $company->name }}{{ $company->code ? ' (' . $company->code . ')' : '' }}
</option>
@endforeach
</select>
</x-searchable-select>
</div>
<div>
<label
@@ -409,16 +410,15 @@
<input type="text" name="location" class="luxury-input w-full"
placeholder="{{ __('Enter machine location') }}">
</div>
<div>
<div class="relative z-10">
<label
class="block text-[11px] font-black text-slate-400 uppercase tracking-[0.2em] mb-2">{{
__('Model') }}</label>
<select name="machine_model_id" required class="luxury-select w-full">
<option value="">{{ __('Select Model') }}</option>
<x-searchable-select name="machine_model_id" required :placeholder="__('Select Model')">
@foreach($models as $model)
<option value="{{ $model->id }}">{{ $model->name }}</option>
@endforeach
</select>
</x-searchable-select>
</div>
<div>
<label

View File

@@ -43,7 +43,7 @@
<div class="grid grid-cols-1 lg:grid-cols-12 gap-8">
<!-- Left Column: Primary Info -->
<div class="lg:col-span-12 space-y-6">
<div class="luxury-card rounded-3xl p-8 animate-luxury-in">
<div class="luxury-card rounded-3xl p-8 animate-luxury-in relative z-20">
<div class="grid grid-cols-1 md:grid-cols-2 gap-8">
<div>
<label class="block text-[11px] font-black text-slate-400 uppercase tracking-[0.2em] mb-2">{{ __('Configuration Name') }} <span class="text-rose-500">*</span></label>
@@ -51,12 +51,16 @@
</div>
<div>
<label class="block text-[11px] font-black text-slate-400 uppercase tracking-[0.2em] mb-2">{{ __('Belongs To Company') }} <span class="text-rose-500">*</span></label>
<select name="company_id" required class="luxury-select w-full">
<option value="">{{ __('Select Company') }}</option>
<x-searchable-select
name="company_id"
required
:selected="old('company_id')"
:placeholder="__('Select Company')"
>
@foreach($companies as $company)
<option value="{{ $company->id }}">{{ $company->name }}</option>
<option value="{{ $company->id }}" data-title="{{ $company->name }}{{ $company->code ? ' ('.$company->code.')' : '' }}">{{ $company->name }}{{ $company->code ? ' ('.$company->code.')' : '' }}</option>
@endforeach
</select>
</x-searchable-select>
</div>
</div>
</div>

View File

@@ -44,7 +44,7 @@
<div class="grid grid-cols-1 lg:grid-cols-12 gap-8">
<!-- Left Column: Primary Info -->
<div class="lg:col-span-12 space-y-6">
<div class="luxury-card rounded-3xl p-8 animate-luxury-in">
<div class="luxury-card rounded-3xl p-8 animate-luxury-in relative z-20">
<div class="grid grid-cols-1 md:grid-cols-2 gap-8">
<div>
<label class="block text-[11px] font-black text-slate-400 uppercase tracking-[0.2em] mb-2">{{ __('Configuration Name') }} <span class="text-rose-500">*</span></label>
@@ -52,12 +52,16 @@
</div>
<div>
<label class="block text-[11px] font-black text-slate-400 uppercase tracking-[0.2em] mb-2">{{ __('Belongs To Company') }} <span class="text-rose-500">*</span></label>
<select name="company_id" required class="luxury-select w-full">
<option value="">{{ __('Select Company') }}</option>
<x-searchable-select
name="company_id"
required
:selected="old('company_id', $paymentConfig->company_id)"
:placeholder="__('Select Company')"
>
@foreach($companies as $company)
<option value="{{ $company->id }}" {{ $paymentConfig->company_id == $company->id ? 'selected' : '' }}>{{ $company->name }}</option>
<option value="{{ $company->id }}" data-title="{{ $company->name }}{{ $company->code ? ' ('.$company->code.')' : '' }}">{{ $company->name }}{{ $company->code ? ' ('.$company->code.')' : '' }}</option>
@endforeach
</select>
</x-searchable-select>
</div>
</div>
</div>

View File

@@ -236,7 +236,7 @@
x-transition:leave="ease-in duration-200"
x-transition:leave-start="opacity-100 translate-y-0 sm:scale-100"
x-transition:leave-end="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
class="inline-block px-8 py-10 overflow-hidden 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">
class="inline-block px-8 py-10 overflow-visible 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">
<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"
@@ -296,7 +296,7 @@
</div>
<!-- Admin Account Section (Account Creation) - Only show when creating -->
<div x-show="!editing" class="space-y-6 pt-6 border-t border-slate-100 dark:border-slate-800">
<div x-show="!editing" class="space-y-6 pt-6 border-t border-slate-100 dark:border-slate-800 relative z-20">
<div class="flex items-center gap-3">
<div class="h-6 w-1 bg-emerald-500 rounded-full"></div>
<h4 class="text-xs font-black text-slate-800 dark:text-white uppercase tracking-widest">
@@ -324,21 +324,24 @@
<label class="text-xs font-black text-slate-500 uppercase tracking-widest pl-1">{{ __('Admin Name') }}</label>
<input type="text" name="admin_name" class="luxury-input w-full" placeholder="{{ __('Admin display name') }}">
</div>
<div class="space-y-2">
<div class="space-y-2 relative z-[30]">
<label class="text-xs font-black text-slate-500 uppercase tracking-widest pl-1">{{ __('Initial Role') }}</label>
<select name="admin_role" class="luxury-select w-full">
<x-searchable-select
name="admin_role"
:hasSearch="false"
>
@foreach($template_roles as $role)
<option value="{{ $role->name }}" {{ $role->name == '客戶管理員角色模板' ? 'selected' : '' }}>
{{ $role->name }}
</option>
@endforeach
</select>
</x-searchable-select>
</div>
</div>
</div>
<!-- Contact Section -->
<div class="space-y-6 pt-6 border-t border-slate-100 dark:border-slate-800">
<div class="space-y-6 pt-6 border-t border-slate-100 dark:border-slate-800 relative z-10">
<div class="flex items-center gap-3">
<div class="h-6 w-1 bg-amber-500 rounded-full"></div>
<h4 class="text-xs font-black text-slate-800 dark:text-white uppercase tracking-widest">{{
@@ -361,13 +364,25 @@
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div class="space-y-2">
<div class="space-y-2 relative z-[20]">
<label class="text-xs font-black text-slate-500 uppercase tracking-widest pl-1">{{
__('Status') }}</label>
<select name="status" x-model="currentCompany.status" class="luxury-select">
<x-searchable-select
name="status"
x-model="currentCompany.status"
:hasSearch="false"
x-init="$watch('currentCompany.status', (value) => {
$nextTick(() => {
const inst = HSSelect.getInstance($el);
if (inst) {
inst.setValue(String(value));
}
});
})"
>
<option value="1">{{ __('Active') }}</option>
<option value="0">{{ __('Disabled') }}</option>
</select>
</x-searchable-select>
</div>
<div class="space-y-2">
<label class="text-xs font-black text-slate-500 uppercase tracking-widest pl-1">{{

View File

@@ -3,67 +3,50 @@
@php
$routeName = request()->route()->getName();
$baseRoute = str_contains($routeName, 'sub-accounts') ? 'admin.data-config.sub-accounts' : 'admin.permission.accounts';
$tab = request('tab', '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-6" x-data="{
showModal: {{ $errors->any() ? 'true' : 'false' }},
editing: {{ old('_method') === 'PUT' || (isset($user) && $errors->any()) ? 'true' : 'false' }},
allRoles: @js($roles),
currentUser: {
id: '{{ old('id') }}',
name: '{{ old('name') }}',
username: '{{ old('username') }}',
email: '{{ old('email') }}',
phone: '{{ old('phone') }}',
company_id: '{{ old('company_id', auth()->user()->isSystemAdmin() ? '' : auth()->user()->company_id) }}',
role: '{{ old('role', '') }}',
status: {{ old('status', 1) }}
<div class="space-y-2 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))
},
isDeleteConfirmOpen: false,
deleteFormAction: '',
confirmDelete(action) {
this.deleteFormAction = action;
this.isDeleteConfirmOpen = true;
},
get filteredRoles() {
if (this.currentUser.company_id === '' || this.currentUser.company_id === null) {
// 系統層級:顯示 is_system = 1 的角色
return this.allRoles.filter(r => r.is_system);
} else {
// 客戶層級:只顯示該公司的角色
let roles = this.allRoles.filter(r => r.company_id == this.currentUser.company_id);
// 如果是系統管理員,額外允許選擇「系統層級的角色範本」(排除 super-admin 以免誤派)
@if(auth()->user()->isSystemAdmin())
let templates = this.allRoles.filter(r => (r.company_id === null || r.company_id === '') && r.name !== 'super-admin');
roles = [...roles, ...templates];
@endif
return roles;
}
},
openCreateModal() {
this.editing = false;
this.currentUser = { id: '', name: '', username: '', email: '', phone: '', company_id: '{{ auth()->user()->isSystemAdmin() ? "" : auth()->user()->company_id }}', role: '', status: 1 };
this.showModal = true;
// 預設選取第一個可用的角色
this.$nextTick(() => {
if (this.filteredRoles.length > 0) {
this.currentUser.role = this.filteredRoles[0].name;
}
});
},
openEditModal(user) {
this.editing = true;
this.currentUser = {
...user,
company_id: user.company_id || '',
role: user.roles && user.roles.length > 0 ? user.roles[0].name : ''
};
this.showModal = true;
}
}">
tab: @js($tab),
roleSelectConfig: @js($roleSelectConfig)
})">
<!-- Header -->
<div class="flex flex-col md:flex-row md:items-center justify-between gap-6">
<div>
@@ -73,52 +56,85 @@
</p>
</div>
<div class="flex items-center gap-3">
@if($tab === 'accounts')
<button @click="openCreateModal()" class="btn-luxury-primary">
<svg class="size-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15"/></svg>
<svg class="size-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
</svg>
<span>{{ __('Add Account') }}</span>
</button>
@endif
</div>
</div>
<!-- Tabs Navigation -->
<div
class="flex items-center gap-1 p-1.5 bg-slate-100 dark:bg-slate-900/50 rounded-2xl w-fit border border-slate-200/50 dark:border-slate-800/50">
<a href="{{ route($baseRoute, ['tab' => 'accounts']) }}"
class="px-8 py-3 rounded-xl text-sm font-black uppercase tracking-widest transition-all {{ $tab === 'accounts' ? 'bg-white dark:bg-slate-800 text-cyan-600 dark:text-cyan-400 shadow-sm shadow-cyan-500/10' : 'text-slate-400 hover:text-slate-600 dark:hover:text-slate-200' }}">
{{ __('Account Management') }}
</a>
<a href="{{ route($baseRoute, ['tab' => 'permissions']) }}"
class="px-8 py-3 rounded-xl text-sm font-black uppercase tracking-widest transition-all {{ $tab === 'permissions' ? 'bg-white dark:bg-slate-800 text-cyan-600 dark:text-cyan-400 shadow-sm shadow-cyan-500/10' : 'text-slate-400 hover:text-slate-600 dark:hover:text-slate-200' }}">
{{ __('Authorized Machines') }}
</a>
</div>
<!-- Accounts Content (Integrated Card) -->
<div class="luxury-card rounded-3xl p-8 animate-luxury-in">
<div class="luxury-card rounded-3xl p-8 animate-luxury-in mt-6">
<!-- Filters & Search -->
<form action="{{ route($baseRoute) }}" method="GET" class="mb-10">
<div class="flex flex-col md:flex-row items-start md:items-center gap-4 w-full md:w-auto">
<div class="relative group w-full md:w-80">
<form action="{{ route($baseRoute) }}" method="GET"
class="flex flex-col md:flex-row md:items-center gap-4 mb-10">
<input type="hidden" name="tab" value="{{ $tab }}">
<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">
<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" y1="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 luxury-input" placeholder="{{ __('Search users...') }}">
<input type="hidden" name="per_page" value="{{ request('per_page', 10) }}">
<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 users...') }}">
</div>
@if(auth()->user()->isSystemAdmin())
<select name="company_id" onchange="this.form.submit()" class="luxury-select w-full md:w-auto min-w-[200px]">
<option value="">{{ __('All Companies') }}</option>
@foreach($companies as $company)
<option value="{{ $company->id }}" {{ request('company_id') == $company->id ? 'selected' : '' }}>{{ $company->name }}</option>
@endforeach
</select>
@endif
<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.submit()" />
</div>
@endif
<input type="hidden" name="per_page" value="{{ request('per_page', 10) }}">
</form>
<div class="overflow-x-auto">
@if($tab === 'accounts')
<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">{{ __('Email') }}</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">
{{ __('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">
{{ __('Email') }}</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">{{ __('Belongs To') }}</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>
@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>
<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">
@@ -126,46 +142,63 @@
<tr class="group hover:bg-slate-50/80 dark:hover:bg-slate-800/40 transition-all duration-300">
<td class="px-6 py-6">
<div class="flex items-center gap-x-4">
<div class="w-10 h-10 rounded-2xl bg-slate-100 dark:bg-slate-800 flex items-center justify-center text-slate-400 border border-slate-200 dark:border-slate-700 overflow-hidden group-hover:bg-cyan-500/10 group-hover:text-cyan-600 dark:group-hover:text-cyan-400 transition-all duration-500">
<div
class="w-10 h-10 rounded-xl bg-slate-100 dark:bg-slate-800 flex items-center justify-center text-slate-400 group-hover:bg-cyan-500 group-hover:text-white transition-all overflow-hidden shadow-sm">
@if($user->avatar)
<img src="{{ Storage::url($user->avatar) }}" class="w-full h-full object-cover">
@else
<span class="text-xs font-black">{{ substr($user->name, 0, 1) }}</span>
<svg class="size-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"
stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round"
d="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="flex flex-col">
<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">{{ $user->name }}</span>
<span class="text-xs font-bold text-slate-500 dark:text-slate-400 mt-0.5 tracking-widest uppercase"><span class="font-mono">{{ $user->username }}</span></span>
<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">{{
$user->name }}</span>
<span
class="text-xs font-bold text-slate-500 dark:text-slate-400 mt-0.5 tracking-widest uppercase"><span
class="font-mono">{{ $user->username }}</span></span>
</div>
</div>
</td>
<td class="px-6 py-6">
<span class="text-xs font-bold text-slate-500 dark:text-slate-400 tracking-widest">{{ $user->email ?? '-' }}</span>
<span class="text-xs font-bold text-slate-500 dark:text-slate-400 tracking-widest">{{
$user->email ?? '-' }}</span>
</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>
<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-lg text-xs font-bold bg-cyan-500/10 text-cyan-600 dark:text-cyan-400 uppercase tracking-widest">{{ __('SYSTEM') }}</span>
<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') }}</span>
@endif
</td>
@endif
<td class="px-6 py-6 text-center">
@foreach($user->roles as $role)
<span class="inline-flex items-center px-2.5 py-1 rounded-lg text-xs font-bold 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">
<span
class="inline-flex items-center px-2.5 py-1 rounded-lg text-xs font-bold 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">
{{ $role->name }}
</span>
@endforeach
</td>
<td class="px-6 py-6 text-center">
@if($user->status)
<span class="inline-flex items-center px-3 py-1 rounded-full text-xs font-bold bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border border-emerald-500/20 tracking-widest uppercase">
<span
class="inline-flex items-center px-3 py-1 rounded-full text-xs font-bold bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border border-emerald-500/20 tracking-widest uppercase">
<span class="w-1.5 h-1.5 rounded-full bg-emerald-500 mr-2 animate-pulse"></span>
{{ __('Active') }}
</span>
@else
<span class="inline-flex items-center px-3 py-1 rounded-full text-xs font-bold bg-slate-100 dark:bg-slate-800 text-slate-500 dark:text-slate-400 border border-slate-200 dark:border-slate-700 tracking-widest uppercase">
<span
class="inline-flex items-center px-3 py-1 rounded-full text-xs font-bold bg-slate-100 dark:bg-slate-800 text-slate-500 dark:text-slate-400 border border-slate-200 dark:border-slate-700 tracking-widest uppercase">
{{ __('Disabled') }}
</span>
@endif
@@ -182,8 +215,7 @@
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>
<form action="{{ route($baseRoute . '.destroy', $user->id) }}"
method="POST"
<form action="{{ route($baseRoute . '.destroy', $user->id) }}" method="POST"
class="inline-block">
@csrf
@method('DELETE')
@@ -192,12 +224,15 @@
class="p-2 rounded-xl bg-slate-50 dark:bg-slate-800 text-slate-400 hover:text-rose-500 dark:hover:text-rose-400 hover:bg-rose-500/5 dark:hover:bg-rose-500/10 border border-transparent hover:border-rose-500/20 transition-all group/btn"
title="{{ __('Delete Account') }}">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" 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" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5"
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>
</form>
@else
<span class="text-[10px] font-black text-slate-300 dark:text-slate-600 uppercase tracking-[0.15em] px-2">{{ __('Protected') }}</span>
<span
class="text-[10px] font-black text-slate-300 dark:text-slate-600 uppercase tracking-[0.15em] px-2">{{
__('Protected') }}</span>
@endif
</div>
</td>
@@ -206,62 +241,196 @@
<tr>
<td colspan="{{ auth()->user()->isSystemAdmin() ? 6 : 5 }}" class="px-6 py-24 text-center">
<div class="flex flex-col items-center gap-3 opacity-20">
<svg class="size-16" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2m16-10a4 4 0 11-8 0 4 4 0 018 0zM23 21v-2a4 4 0 00-3-3.87m-4-12a4 4 0 010 7.75"/></svg>
<p class="text-slate-400 font-extrabold tracking-widest uppercase text-xs">{{ __('No accounts found') }}</p>
<svg class="size-16" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
d="M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2m16-10a4 4 0 11-8 0 4 4 0 018 0zM23 21v-2a4 4 0 00-3-3.87m-4-12a4 4 0 010 7.75" />
</svg>
<p class="text-slate-400 font-extrabold tracking-widest uppercase text-xs">{{ __('No
accounts found') }}</p>
</div>
</td>
</tr>
@endforelse
</tbody>
</table>
@else
<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>
@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">
{{ __('Authorized Machines') }}</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">
{{ __('Action') }}</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-50 dark:divide-slate-800/80">
@foreach ($users as $account)
<tr class="group hover:bg-slate-50/80 dark:hover:bg-slate-800/40 transition-all duration-300">
<td class="px-6 py-6">
<div class="flex items-center gap-x-4">
<div
class="w-10 h-10 rounded-xl bg-slate-100 dark:bg-slate-800 flex items-center justify-center text-slate-400 group-hover:bg-cyan-500 group-hover:text-white transition-all overflow-hidden shadow-sm">
@if($account->avatar)
<img src="{{ Storage::url($account->avatar) }}" class="w-full h-full object-cover">
@else
<svg class="size-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"
stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round"
d="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="flex flex-col">
<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">{{
$account->name }}</span>
<span
class="text-xs font-bold text-slate-500 dark:text-slate-400 mt-0.5 tracking-widest uppercase"><span
class="font-mono">{{ $account->username }}</span></span>
</div>
</div>
</td>
@if(auth()->user()->isSystemAdmin())
<td class="px-6 py-6">
@if($account->company)
<span
class="text-xs font-bold text-slate-500 dark:text-slate-400 tracking-widest uppercase">{{
$account->company->name }}</span>
@else
<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') }}</span>
@endif
</td>
@endif
<td class="px-6 py-6 min-w-[240px]">
<div class="flex flex-wrap gap-1.5 overflow-hidden items-center"
id="machines-container-{{ $account->id }}">
@if(!$account->company_id)
<span
class="px-3 py-1 text-[10px] bg-cyan-500/10 text-cyan-600 dark:text-cyan-400 rounded-lg border border-cyan-500/20 uppercase font-black tracking-[0.2em] shadow-sm">
{{ __('Full Access') }}
</span>
@else
@php $assigned = $account->machines; @endphp
@if($assigned->isNotEmpty())
@foreach($assigned->take(3) as $machine)
<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 shadow-sm">
{{ $machine->name }}
</span>
@endforeach
@if($assigned->count() > 3)
<span
class="px-2 py-0.5 text-xs bg-cyan-50 dark:bg-cyan-500/10 text-cyan-500 dark:text-cyan-400 rounded border border-cyan-100 dark:border-cyan-500/20 uppercase font-bold tracking-widest shadow-sm cursor-help transition-all hover:bg-cyan-100 dark:hover:bg-cyan-500/20"
title="{{ $assigned->pluck('name')->implode(', ') }}">
+{{ $assigned->count() - 3 }}
</span>
@endif
@else
<span class="text-[10px] font-bold text-slate-400 italic">{{ __('No machines assigned')
}}</span>
@endif
@endif
</div>
</td>
<td class="px-6 py-6 text-right">
@if(!$account->company_id)
<span
class="text-[10px] font-black text-slate-300 dark:text-slate-600 uppercase tracking-[0.15em] px-2">
{{ __('System Default') }}
</span>
@else
<button @click="openMachineModal({{ $account->id }}, '{{ $account->name }}')"
class="btn-luxury-primary !px-4 !py-2 !text-[11px] !shadow-sm uppercase tracking-widest">
<svg class="w-3.5 h-3.5 mr-1.5 inline-block" fill="none" stroke="currentColor"
viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5"
d="M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4">
</path>
</svg>
<span class="align-middle">{{ __('Assign') }}</span>
</button>
@endif
</td>
</tr>
@endforeach
</tbody>
</table>
@endif
</div>
<div class="mt-8 border-t border-slate-100 dark:border-slate-800 pt-6">
{{ $users->links('vendor.pagination.luxury') }}
<div class="mt-8 border-t border-slate-100/50 dark:border-slate-800/50 pt-6">
{{ $users->appends(['tab' => $tab])->links('vendor.pagination.luxury') }}
</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 transition-opacity bg-slate-900/60 backdrop-blur-sm" @click="showModal = false"></div>
<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 transition-opacity 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:translate-y-0 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 translate-y-0 sm:scale-100" x-transition:leave-end="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
class="inline-block px-8 py-10 overflow-hidden 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">
<div x-show="showModal" x-transition:enter="ease-out duration-300"
x-transition:enter-start="opacity-0 translate-y-4 sm:translate-y-0 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 translate-y-0 sm:scale-100"
x-transition:leave-end="opacity-0 translate-y-4 sm:translate-y-0 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="size-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M6 18L18 6M6 6l12 12"/></svg>
<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="size-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5"
d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<form :action="editing ? '{{ route($baseRoute) }}/' + currentUser.id : '{{ route($baseRoute . '.store') }}'" method="POST" class="space-y-6">
<form
: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="space-y-6">
<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') }}">
@error('name')
<p class="text-[10px] font-bold text-rose-500 mt-1 pl-1 uppercase tracking-tight">{{ $message }}</p>
@enderror
<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') }}">
@error('username')
<p class="text-[10px] font-bold text-rose-500 mt-1 pl-1 uppercase tracking-tight">{{ $message }}</p>
@enderror
<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>
@@ -270,87 +439,425 @@
<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') }}">
@error('email')
<p class="text-[10px] font-bold text-rose-500 mt-1 pl-1 uppercase tracking-tight">{{ $message }}</p>
@enderror
<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">
@error('phone')
<p class="text-[10px] font-bold text-rose-500 mt-1 pl-1 uppercase tracking-tight">{{ $message }}</p>
@enderror
<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 mb-6">
<label class="text-xs font-black text-slate-500 uppercase tracking-widest pl-1">{{ __('Company') }}</label>
<select name="company_id" x-model="currentUser.company_id" class="luxury-select @error('company_id') border-rose-500 @enderror"
@change="$nextTick(() => { if (filteredRoles.length > 0 && !filteredRoles.find(r => r.name === currentUser.role)) { currentUser.role = filteredRoles[0].name; } })">
<option value="">{{ __('SYSTEM') }}</option>
<div class="space-y-2 mb-6 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()">
{{-- 選項由組件根據 placeholder 自動生成 value=' ' 的項目 --}}
@foreach($companies as $company)
<option value="{{ $company->id }}">{{ $company->name }}</option>
<option value="{{ $company->id }}" data-title="{{ $company->name }}">
{{ $company->name }}
</option>
@endforeach
</select>
@error('company_id')
<p class="text-[10px] font-bold text-rose-500 mt-1 pl-1 uppercase tracking-tight">{{ $message }}</p>
@enderror
</x-searchable-select>
</div>
@endif
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<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>
<select name="role" x-model="currentUser.role" class="luxury-select @error('role') border-rose-500 @enderror">
<template x-for="role in filteredRoles" :key="role.id">
<option :value="role.name" x-text="role.name"></option>
</template>
</select>
@error('role')
<p class="text-[10px] font-bold text-rose-500 mt-1 pl-1 uppercase tracking-tight">{{ $message }}</p>
@enderror
<div id="role-select-wrapper" class="relative">
<!-- updateRoleSelect() 動態渲染 -->
</div>
</div>
<div class="space-y-2">
<label class="text-xs font-black text-slate-500 uppercase tracking-widest pl-1">{{ __('Status') }}</label>
<select name="status" x-model="currentUser.status" class="luxury-select @error('status') border-rose-500 @enderror">
<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>
</select>
@error('status')
<p class="text-[10px] font-bold text-rose-500 mt-1 pl-1 uppercase tracking-tight">{{ $message }}</p>
@enderror
</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>
<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>
<input type="password" name="password" :required="!editing" class="luxury-input @error('password') border-rose-500 @enderror" placeholder="••••••••">
@error('password')
<p class="text-[10px] font-bold text-rose-500 mt-1 pl-1 uppercase tracking-tight">{{ $message }}</p>
@enderror
<input type="password" name="password" :required="!editing"
class="luxury-input @error('password') border-rose-500 @enderror"
placeholder="••••••••">
</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="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>
</div>
</form>
</div>
</div>
</div>
<!-- Global Delete Confirm Modal -->
<x-delete-confirm-modal :message="__('Are you sure you want to delete this account? This action cannot be undone.')" />
<!-- Machine Assignment Modal -->
<template x-teleport="body">
<div x-show="showMachineModal"
class="fixed inset-0 z-[100] flex items-center justify-center overflow-y-auto px-4 py-8" x-cloak>
<div class="fixed inset-0 bg-slate-900/60 backdrop-blur-sm transition-opacity" x-show="showMachineModal"
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"
@click="showMachineModal = false"></div>
<div class="luxury-card !bg-white dark:!bg-slate-900 w-full max-w-2xl mx-auto rounded-[2.5rem] shadow-2xl relative overflow-hidden animate-luxury-in border border-slate-100 dark:border-slate-800"
@click.stop x-show="showMachineModal" 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
class="px-10 py-8 border-b border-slate-50 dark:border-slate-800/50 flex justify-between items-center relative z-10">
<div>
<h3 class="text-2xl font-black text-slate-800 dark:text-white tracking-tight font-display">{{
__('Assign Machines') }}</h3>
<p class="text-[11px] font-black text-cyan-500 uppercase tracking-[0.2em] mt-1"
x-text="selectedUserName"></p>
</div>
</div>
<div class="p-8 max-h-[60vh] overflow-y-auto custom-scrollbar">
<template x-if="loading">
<div class="flex flex-col items-center justify-center py-20">
<div
class="w-12 h-12 border-4 border-cyan-500/20 border-t-cyan-500 rounded-full animate-spin">
</div>
<p class="mt-4 text-xs font-bold text-slate-400 uppercase tracking-widest">{{ __('Loading
machines...') }}</p>
</div>
</template>
<template x-if="!loading && machines.length === 0">
<div class="text-center py-20 text-slate-400 font-bold uppercase tracking-widest">{{ __('No
machines available') }}</div>
</template>
<template x-if="!loading && machines.length > 0">
<div class="space-y-6">
<div class="flex items-center justify-between px-2">
<label class="flex items-center gap-3 cursor-pointer group">
<input type="checkbox" @change="toggleAll" :checked="isAllSelected"
class="size-5 rounded-lg border-2 border-slate-300 dark:border-white/20 text-cyan-600 focus:ring-cyan-500 bg-transparent">
<span
class="text-xs font-black text-slate-500 dark:text-slate-400 uppercase tracking-widest group-hover:text-cyan-500 transition-colors">{{
__('Select All') }}</span>
</label>
<span class="text-[10px] font-black text-slate-400 uppercase tracking-widest"
x-text="`${assignedIds.length} / ${machines.length} {{ __('Selected') }}`"></span>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<template x-for="machine in machines" :key="machine.id">
<label
class="group relative flex items-center p-4 rounded-2xl bg-slate-50 dark:bg-slate-800/50 border-2 border-transparent hover:border-cyan-500/30 transition-all cursor-pointer has-[:checked]:border-cyan-500 has-[:checked]:bg-cyan-500/[0.03]">
<input type="checkbox" :value="machine.id.toString()" x-model="assignedIds"
class="size-5 rounded-lg border-2 border-slate-300 dark:border-white/20 text-cyan-600 focus:ring-cyan-500 bg-transparent">
<div class="ml-4 flex-1">
<div class="text-sm font-black text-slate-700 dark:text-slate-200 group-hover:text-cyan-600 dark:group-hover:text-cyan-400 transition-colors"
x-text="machine.name"></div>
<div class="text-[10px] font-mono font-bold text-slate-400 uppercase tracking-widest mt-0.5"
x-text="machine.serial_no"></div>
</div>
</label>
</template>
</div>
</div>
</template>
</div>
<div
class="px-10 py-6 bg-slate-50 dark:bg-slate-900/50 flex justify-end gap-3 border-t border-slate-100 dark:border-slate-800">
<button @click="showMachineModal = false" class="btn-luxury-ghost">{{ __('Cancel') }}</button>
<button @click="savePermissions" class="btn-luxury-primary px-8" :disabled="saving"
:class="saving ? 'opacity-50 cursor-not-allowed' : ''">
<template x-if="!saving"><span>{{ __('Save Changes') }}</span></template>
<template x-if="saving">
<div class="flex items-center gap-2">
<div class="w-4 h-4 border-2 border-white/20 border-t-white rounded-full animate-spin">
</div><span>{{ __('Saving...') }}</span>
</div>
</template>
</button>
</div>
</div>
</div>
</template>
<!-- Global Delete Confirm Modal -->
<x-delete-confirm-modal
:message="__('Are you sure you want to delete this account? This action cannot be undone.')" />
</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), // Added logic for editing when errors exist on an existing user
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
},
roleSelectConfig: initData.roleSelectConfig,
isDeleteConfirmOpen: false,
deleteFormAction: '',
confirmDelete(action) {
this.deleteFormAction = action;
this.isDeleteConfirmOpen = true;
},
tab: initData.tab,
showMachineModal: false,
selectedUserId: '',
selectedUserName: '',
machines: [],
assignedIds: [],
loading: false,
saving: false,
get isAllSelected() {
return this.machines.length > 0 && this.assignedIds.length === this.machines.length;
},
async openMachineModal(userId, userName) {
this.selectedUserId = userId;
this.selectedUserName = userName;
this.showMachineModal = true;
this.loading = true;
this.assignedIds = [];
try {
const url = `{{ route('admin.machines.permissions.accounts.get', 'USER_ID') }}`.replace('USER_ID', userId);
const response = await fetch(url);
const data = await response.json();
this.machines = data.machines;
this.assignedIds = data.assigned_ids.map(id => id.toString());
} catch (error) {
console.error('Error fetching data:', error);
window.Alpine.store('toast').show('{{ __("Failed to fetch machine data.") }}', 'error');
} finally {
this.loading = false;
}
},
toggleAll() {
if (this.isAllSelected) {
this.assignedIds = [];
} else {
this.assignedIds = this.machines.map(m => m.id.toString());
}
},
async savePermissions() {
this.saving = true;
try {
const url = `{{ route('admin.machines.permissions.accounts.sync', 'USER_ID') }}`.replace('USER_ID', this.selectedUserId);
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': '{{ csrf_token() }}'
},
body: JSON.stringify({ machine_ids: this.assignedIds })
});
const data = await response.json();
if (data.success) {
this.showMachineModal = false;
window.Alpine.store('toast').show(data.message, 'success');
const container = document.getElementById(`machines-container-${this.selectedUserId}`);
if (container) {
const assigned = data.assigned_machines;
if (assigned.length > 0) {
const visible = assigned.slice(0, 3);
const extraCount = assigned.length - 3;
const allNames = assigned.map(m => m.name).join(', ');
let html = visible.map(m => `<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 shadow-sm">${m.name}</span>`).join('');
if (extraCount > 0) {
html += `<span class="px-2 py-0.5 text-xs bg-cyan-50 dark:bg-cyan-500/10 text-cyan-500 dark:text-cyan-400 rounded border border-cyan-100 dark:border-cyan-500/20 uppercase font-bold tracking-widest shadow-sm cursor-help transition-all hover:bg-cyan-100 dark:hover:bg-cyan-500/20" title="${allNames}">+${extraCount}</span>`;
}
container.innerHTML = html;
} else {
container.innerHTML = `<span class="text-[10px] font-bold text-slate-400 italic">{{ __('No machines assigned') }}</span>`;
}
}
} else {
window.Alpine.store('toast').show(data.error || '{{ __("Failed to save permissions.") }}', 'error');
}
} catch (error) {
console.error('Error saving permissions:', error);
window.Alpine.store('toast').show('{{ __("An error occurred while saving.") }}', 'error');
} finally {
this.saving = false;
}
},
get filteredRoles() {
const companyId = this.currentUser.company_id;
if (!companyId || companyId.toString().trim() === '') {
return this.allRoles.filter(r => !r.company_id || r.company_id.toString().trim() === '');
} else {
let companyRoles = this.allRoles.filter(r => r.company_id == companyId);
if (companyRoles.length > 0) {
return companyRoles;
} else {
return this.allRoles.filter(r => !r.company_id || r.company_id.toString().trim() === '');
}
}
},
openCreateModal() {
this.editing = false;
const initialCompanyId = initData.oldValues.company_id;
let initialRole = '';
const roles = this.filteredRoles;
if (roles.length > 0) {
initialRole = roles[0].name;
} else if (this.allRoles.length > 0) {
const systemRoles = this.allRoles.filter(r => !r.company_id || r.company_id.toString().trim() === '');
if (systemRoles.length > 0) initialRole = systemRoles[0].name;
}
this.currentUser = {
id: '',
name: '',
username: '',
email: '',
phone: '',
company_id: initialCompanyId,
role: initialRole,
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 && 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 valStr = (value !== undefined && value !== null && value.toString().trim() !== '') ? value.toString() : ' ';
el.value = valStr;
el.dispatchEvent(new Event('change', { bubbles: true }));
if (window.HSSelect && window.HSSelect.getInstance(el)) {
window.HSSelect.getInstance(el).setValue(valStr);
}
}
});
},
updateRoleSelect() {
this.$nextTick(() => {
const wrapper = document.getElementById('role-select-wrapper');
if (!wrapper) return;
const configStr = JSON.stringify(this.roleSelectConfig);
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 = '';
}
const oldSelect = document.getElementById('modal-account-role');
if (oldSelect && window.HSSelect && window.HSSelect.getInstance(oldSelect)) {
window.HSSelect.getInstance(oldSelect).destroy();
}
wrapper.innerHTML = '';
const selectEl = document.createElement('select');
selectEl.name = 'role';
selectEl.id = 'modal-account-role';
selectEl.className = 'hidden';
selectEl.setAttribute('data-hs-select', configStr);
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);
if (window.HSStaticMethods && window.HSStaticMethods.autoInit) {
window.HSStaticMethods.autoInit(['select']);
}
selectEl.addEventListener('change', (e) => {
this.currentUser.role = e.target.value;
});
this._roleGeneration = (this._roleGeneration || 0) + 1;
const currentGen = this._roleGeneration;
const waitForHSSelect = (attempts = 0) => {
if (currentGen !== this._roleGeneration) return;
const select = window.HSSelect ? window.HSSelect.getInstance(selectEl) : null;
if (select) {
select.setValue(this.currentUser.role);
} else if (attempts < 20) {
setTimeout(() => waitForHSSelect(attempts + 1), 50);
}
};
waitForHSSelect();
});
},
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

@@ -1,71 +0,0 @@
@extends('layouts.admin')
@section('content')
<div class="container mx-auto px-6 py-8">
<h3 class="text-gray-900 dark:text-gray-300 text-3xl font-medium">{{ __('Edit Machine') }}</h3>
<div class="mt-8">
<form action="{{ route('admin.machines.update', $machine) }}" method="POST"
class="bg-white dark:bg-gray-800 rounded-lg shadow-xl overflow-hidden p-6 space-y-6">
@csrf
@method('PUT')
<div>
<label for="name" class="block text-sm font-medium text-gray-700 dark:text-gray-400">{{ __('Machine
Name') }}</label>
<input type="text" name="name" id="name" value="{{ old('name', $machine->name) }}"
class="mt-1 block w-full bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm py-2 px-3 text-gray-900 dark:text-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
required>
@error('name') <span class="text-red-500 text-sm">{{ $message }}</span> @enderror
</div>
<div>
<label for="location" class="block text-sm font-medium text-gray-700 dark:text-gray-400">{{
__('Location') }}</label>
<input type="text" name="location" id="location" value="{{ old('location', $machine->location) }}"
class="mt-1 block w-full bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm py-2 px-3 text-gray-900 dark:text-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm">
@error('location') <span class="text-red-500 text-sm">{{ $message }}</span> @enderror
</div>
<div>
<label for="status" class="block text-sm font-medium text-gray-700 dark:text-gray-400">{{ __('Status')
}}</label>
<select name="status" id="status"
class="mt-1 block w-full bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm py-2 px-3 text-gray-900 dark:text-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm">
<option value="offline" {{ $machine->status == 'offline' ? 'selected' : '' }}>{{ __('Offline') }}
</option>
<option value="online" {{ $machine->status == 'online' ? 'selected' : '' }}>{{ __('Connecting...')
}}</option>
<option value="error" {{ $machine->status == 'error' ? 'selected' : '' }}>{{ __('Error') }}</option>
</select>
@error('status') <span class="text-red-500 text-sm">{{ $message }}</span> @enderror
</div>
<div>
<label for="temperature" class="block text-sm font-medium text-gray-700 dark:text-gray-400">{{
__('Temperature') }} (°C)</label>
<input type="number" step="0.1" name="temperature" id="temperature"
value="{{ old('temperature', $machine->temperature) }}"
class="mt-1 block w-full bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm py-2 px-3 text-gray-900 dark:text-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm">
@error('temperature') <span class="text-red-500 text-sm">{{ $message }}</span> @enderror
</div>
<div>
<label for="firmware_version" class="block text-sm font-medium text-gray-700 dark:text-gray-400">{{
__('Firmware Version') }}</label>
<input type="text" name="firmware_version" id="firmware_version"
value="{{ old('firmware_version', $machine->firmware_version) }}"
class="mt-1 block w-full bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm py-2 px-3 text-gray-900 dark:text-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm">
@error('firmware_version') <span class="text-red-500 text-sm">{{ $message }}</span> @enderror
</div>
<div class="flex justify-end">
<a href="{{ route('admin.machines.index') }}"
class="bg-gray-200 dark:bg-gray-600 hover:bg-gray-300 dark:hover:bg-gray-500 text-gray-800 dark:text-white font-bold py-2 px-4 rounded mr-2">{{
__('Cancel') }}</a>
<button type="submit" class="btn-luxury-primary">{{ __('Update') }}</button>
</div>
</form>
</div>
</div>
@endsection

View File

@@ -3,6 +3,16 @@
@section('content')
<div class="space-y-6" x-data="{
selectedPermissions: {{ json_encode($role->permissions->pluck('name')->toArray()) }},
@php
$availablePermissions = $all_permissions->flatten();
if (!$role->is_system) {
$availablePermissions = $availablePermissions->filter(function($p) {
return !str_starts_with($p->name, 'menu.basic') &&
!str_starts_with($p->name, 'menu.permissions');
});
}
@endphp
availableCount: {{ $availablePermissions->count() }},
activeCategory: '',
toggleCategory(category, permissions) {
const allSelected = permissions.every(p => this.selectedPermissions.includes(p));
@@ -20,6 +30,32 @@
const selectedCount = permissions.filter(p => this.selectedPermissions.includes(p)).length;
return selectedCount > 0 && selectedCount < permissions.length;
},
toggleParent(parentName, childrenNames) {
const isSelected = this.selectedPermissions.includes(parentName);
if (isSelected) {
// 取消父項目與所有子項目
this.selectedPermissions = this.selectedPermissions.filter(p => p !== parentName && !childrenNames.includes(p));
} else {
// 勾選父項目
if (!this.selectedPermissions.includes(parentName)) {
this.selectedPermissions.push(parentName);
}
// 勾選所有尚未勾選的子項目
childrenNames.forEach(p => {
if (!this.selectedPermissions.includes(p)) this.selectedPermissions.push(p);
});
}
},
isParentSelected(parentName, childrenNames) {
return this.selectedPermissions.includes(parentName) &&
childrenNames.every(p => this.selectedPermissions.includes(p));
},
isParentPartial(parentName, childrenNames) {
const hasParent = this.selectedPermissions.includes(parentName);
const selectedChildrenCount = childrenNames.filter(p => this.selectedPermissions.includes(p)).length;
return (hasParent && selectedChildrenCount < childrenNames.length) ||
(!hasParent && selectedChildrenCount > 0);
},
scrollTo(id) {
const el = document.getElementById(id);
if (el) {
@@ -140,7 +176,7 @@
<span class="text-[10px] font-black text-slate-400 uppercase tracking-widest mb-1">{{ __('Total Selected') }}</span>
<div class="flex items-baseline gap-1">
<span class="text-3xl font-display font-black text-cyan-500" x-text="selectedPermissions.length">0</span>
<span class="text-xs font-bold text-slate-400">/ {{ $all_permissions->flatten()->count() }}</span>
<span class="text-xs font-bold text-slate-400">/ <span x-text="availableCount"></span></span>
</div>
</div>
<div class="w-12 h-12 rounded-2xl bg-emerald-500/5 flex items-center justify-center text-emerald-500">
@@ -156,6 +192,13 @@
<div class="flex-1 w-full space-y-12">
@foreach($all_permissions as $group => $permissions)
@php
// 如果非系統角色,過濾掉敏感權限
if (!$role->is_system && $group === 'menu') {
$permissions = $permissions->filter(function($p) {
return !str_starts_with($p->name, 'menu.basic') &&
!str_starts_with($p->name, 'menu.permissions');
});
}
$groupId = 'group-' . $group;
$groupPermissions = $permissions->pluck('name')->toArray();
@endphp
@@ -218,9 +261,11 @@
<div class="flex items-center pr-1">
<label class="relative inline-flex items-center cursor-pointer">
<input type="checkbox" name="permissions[]" value="{{ $parent->name }}"
x-model="selectedPermissions"
:checked="selectedPermissions.includes('{{ $parent->name }}')"
@change="toggleParent('{{ $parent->name }}', {{ json_encode($children->pluck('name')->toArray()) }})"
class="sr-only peer">
<div class="w-11 h-6 bg-slate-200 dark:bg-slate-700 rounded-full peer peer-focus:outline-none peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-0.5 after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-cyan-500 transition-all duration-200 shadow-inner"></div>
<div class="w-11 h-6 bg-slate-200 dark:bg-slate-700 rounded-full peer peer-focus:outline-none peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-0.5 after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-cyan-500 transition-all duration-200 shadow-inner"
:class="isParentPartial('{{ $parent->name }}', {{ json_encode($children->pluck('name')->toArray()) }}) ? 'ring-2 ring-cyan-500/50' : ''"></div>
</label>
</div>
</div>
@@ -237,9 +282,9 @@
<div class="relative flex items-center flex-shrink-0">
<input type="checkbox"
name="permissions[]"
value="{{ $child->id }}"
class="w-4 h-4 rounded border-2 border-slate-300 dark:border-slate-700 text-cyan-500 focus:ring-cyan-500/20 transition-all cursor-pointer accent-cyan-500"
{{ $role->hasPermissionTo($child->name) ? 'checked' : '' }}>
value="{{ $child->name }}"
x-model="selectedPermissions"
class="w-4 h-4 rounded border-2 border-slate-300 dark:border-slate-700 text-cyan-500 focus:ring-cyan-500/20 transition-all cursor-pointer accent-cyan-500">
</div>
</label>
@endforeach

View File

@@ -66,13 +66,16 @@
@if(auth()->user()->isSystemAdmin())
<div class="relative">
<select name="company_id" onchange="this.form.submit()" class="py-2.5 pl-4 pr-10 block w-full md:w-60 luxury-input">
<option value="">{{ __('All Affiliations') }}</option>
<option value="system" {{ request('company_id') === 'system' ? 'selected' : '' }}>{{ __('System Level') }}</option>
@foreach($companies as $company)
<option value="{{ $company->id }}" {{ request('company_id') == $company->id ? 'selected' : '' }}>{{ $company->name }}</option>
@endforeach
</select>
<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.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) }}">
@@ -108,11 +111,11 @@
</td>
<td class="px-6 py-6">
@if($role->is_system)
<span class="inline-flex items-center px-2.5 py-1 rounded-lg text-xs font-bold bg-slate-100 dark:bg-slate-800 text-slate-600 dark:text-slate-300 border border-slate-200 dark:border-slate-800 uppercase tracking-wider">
<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="inline-flex items-center px-2.5 py-1 rounded-lg text-xs font-bold bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border border-emerald-500/20 tracking-wider">
<span class="text-xs font-bold text-slate-500 dark:text-slate-400 tracking-widest uppercase">
{{ $role->company->name ?? __('Company Level') }}
</span>
@endif

View File

@@ -0,0 +1,50 @@
@props([
'name' => null,
'options' => [],
'selected' => null,
'placeholder' => null,
'id' => null,
'hasSearch' => true,
])
@php
$id = $id ?? $name ?? 'select-' . uniqid();
$options = is_iterable($options) ? $options : [];
// Skill Standard: Use " " for empty/all options to bypass Preline hiding while staying 'blank'
$isEmptySelected = (is_null($selected) || (string)$selected === '' || (string)$selected === ' ');
$config = [
"hasSearch" => (bool)$hasSearch,
"searchPlaceholder" => $placeholder ?: __('Search...'),
"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
<div {{ $attributes->merge(['class' => 'relative w-full'])->only('class') }}>
<select name="{{ $name }}" id="{{ $id }}" data-hs-select='{!! json_encode($config) !!}' class="hidden" {{ $attributes->except(['options', 'selected', 'placeholder', 'id', 'name', 'class', 'hasSearch']) }}>
@if($placeholder)
<option value=" " {{ $isEmptySelected ? 'selected' : '' }} data-title="{{ $placeholder }}">
{{ $placeholder }}
</option>
@endif
{{ $slot }}
@foreach($options as $v => $l)
@php
$val = is_object($l) ? ($l->id ?? $l->value) : $v;
$text = is_object($l) ? ($l->name ?? $l->label ?? $l->title) : $l;
@endphp
<option value="{{ $val }}" {{ (string)$selected === (string)$val ? 'selected' : '' }} data-title="{{ $text }}">
{{ $text }}
</option>
@endforeach
</select>
</div>

View File

@@ -7,46 +7,46 @@
}
@endphp
<div class="fixed top-8 left-1/2 -translate-x-1/2 z-[99999] w-full max-w-sm px-4 space-y-3 pointer-events-none">
@if(session('success'))
<div x-data="{ show: true }"
x-show="show"
x-init="setTimeout(() => show = false, 3000)"
<div x-data="{
toasts: [],
add(message, type = 'success') {
const id = Date.now();
this.toasts.push({ id, message, type });
setTimeout(() => {
this.toasts = this.toasts.filter(t => t.id !== id);
}, type === 'success' ? 3000 : 5000);
}
}"
@toast.window="add($event.detail.message, $event.detail.type)"
x-init="
window.Alpine.store('toast', {
show(message, type = 'success') {
window.dispatchEvent(new CustomEvent('toast', { detail: { message, type } }));
}
});
@if(session('success')) add('{{ session('success') }}', 'success'); @endif
@if(session('error')) add('{{ session('error') }}', 'error'); @endif
@foreach($allErrors as $error) add('{{ addslashes($error) }}', 'error'); @endforeach
"
class="fixed top-8 left-1/2 -translate-x-1/2 z-[99999] w-full max-w-sm px-4 space-y-3 pointer-events-none">
<template x-for="toast in toasts" :key="toast.id">
<div x-show="true"
x-transition:enter="transition ease-out duration-500"
x-transition:enter-start="opacity-0 transform -translate-y-4 scale-95"
x-transition:enter-end="opacity-100 transform translate-y-0 scale-100"
x-transition:leave="transition ease-in duration-300"
x-transition:leave-start="opacity-100 transform translate-y-0 scale-100"
x-transition:leave-end="opacity-0 transform -translate-y-4 scale-95"
class="p-4 bg-white dark:bg-slate-900 border border-emerald-500/30 text-emerald-600 dark:text-emerald-400 rounded-2xl font-bold shadow-[0_20px_50px_rgba(16,185,129,0.15)] flex items-center gap-3 pointer-events-auto backdrop-blur-xl bg-opacity-90 dark:bg-opacity-90 animate-luxury-in">
<div class="size-8 bg-emerald-500/20 rounded-xl flex items-center justify-center flex-shrink-0 text-emerald-500">
<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.5" d="M5 13l4 4L19 7"/></svg>
:class="toast.type === 'success'
? 'border-emerald-500/30 text-emerald-600 dark:text-emerald-400 shadow-[0_20px_50px_rgba(16,185,129,0.15)]'
: 'border-rose-500/30 text-rose-600 dark:text-rose-400 shadow-[0_20px_50px_rgba(244,63,94,0.15)]'"
class="p-4 bg-white dark:bg-slate-900 border rounded-2xl font-bold flex items-center gap-3 pointer-events-auto backdrop-blur-xl bg-opacity-90 dark:bg-opacity-90 animate-luxury-in">
<div :class="toast.type === 'success' ? 'bg-emerald-500/20 text-emerald-500' : 'bg-rose-500/20 text-rose-500'"
class="size-8 rounded-xl flex items-center justify-center flex-shrink-0">
<svg x-show="toast.type === 'success'" class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M5 13l4 4L19 7"/></svg>
<svg x-show="toast.type === 'error'" class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/></svg>
</div>
<span>{{ session('success') }}</span>
<span x-text="toast.message"></span>
</div>
@endif
@if(session('error') || count($allErrors) > 0)
<div x-data="{ show: true }"
x-show="show"
x-init="setTimeout(() => show = false, 5000)"
x-transition:enter="transition ease-out duration-500"
x-transition:enter-start="opacity-0 transform -translate-y-4 scale-95"
x-transition:enter-end="opacity-100 transform translate-y-0 scale-100"
x-transition:leave="transition ease-in duration-300"
x-transition:leave-start="opacity-100 transform translate-y-0 scale-100"
x-transition:leave-end="opacity-0 transform -translate-y-4 scale-95"
class="p-4 bg-white dark:bg-slate-900 border border-rose-500/30 text-rose-600 dark:text-rose-400 rounded-2xl font-bold shadow-[0_20px_50px_rgba(244,63,94,0.15)] flex items-center gap-3 pointer-events-auto backdrop-blur-xl bg-opacity-90 dark:bg-opacity-90 animate-luxury-in">
<div class="size-8 bg-rose-500/20 rounded-xl flex items-center justify-center flex-shrink-0 text-rose-500">
<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.5" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/></svg>
</div>
<span>
@if(session('error'))
{{ session('error') }}
@else
{{ $allErrors[0] ?? __('Please check the form for errors.') }}
@endif
</span>
</div>
@endif
</template>
</div>

View File

@@ -58,7 +58,7 @@
<ul class="luxury-submenu" data-sidebar-sub>
<li><a class="flex items-center gap-x-3.5 py-2 px-2.5 text-sm transition-colors rounded-lg {{ request()->routeIs('admin.machines.logs') ? 'text-slate-900 dark:text-white bg-slate-100 dark:bg-white/5' : 'text-slate-500 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white' }}" href="{{ route('admin.machines.logs') }}">{{ __('Machine Logs') }}</a></li>
<li><a class="flex items-center gap-x-3.5 py-2 px-2.5 text-sm transition-colors rounded-lg {{ request()->routeIs('admin.machines.index') ? 'text-slate-900 dark:text-white bg-slate-100 dark:bg-white/5' : 'text-slate-500 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white' }}" href="{{ route('admin.machines.index') }}">{{ __('Machine List') }}</a></li>
<li><a class="flex items-center gap-x-3.5 py-2 px-2.5 text-sm transition-colors rounded-lg {{ request()->routeIs('admin.machines.permissions') ? 'text-slate-900 dark:text-white bg-slate-100 dark:bg-white/5' : 'text-slate-500 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white' }}" href="{{ route('admin.machines.permissions') }}">{{ __('Machine Permissions') }}</a></li>
<li><a class="flex items-center gap-x-3.5 py-2 px-2.5 text-sm transition-colors rounded-lg {{ request()->routeIs('admin.machines.utilization') ? 'text-slate-900 dark:text-white bg-slate-100 dark:bg-white/5' : 'text-slate-500 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white' }}" href="{{ route('admin.machines.utilization') }}">{{ __('Utilization Rate') }}</a></li>
<li><a class="flex items-center gap-x-3.5 py-2 px-2.5 text-sm transition-colors rounded-lg {{ request()->routeIs('admin.machines.expiry') ? 'text-slate-900 dark:text-white bg-slate-100 dark:bg-white/5' : 'text-slate-500 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white' }}" href="{{ route('admin.machines.expiry') }}">{{ __('Expiry Management') }}</a></li>
<li><a class="flex items-center gap-x-3.5 py-2 px-2.5 text-sm transition-colors rounded-lg {{ request()->routeIs('admin.machines.maintenance') ? 'text-slate-900 dark:text-white bg-slate-100 dark:bg-white/5' : 'text-slate-500 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white' }}" href="{{ route('admin.machines.maintenance') }}">{{ __('Maintenance Records') }}</a></li>

View File

@@ -39,7 +39,9 @@ Route::middleware(['auth', 'verified', 'tenant.access'])->prefix('admin')->name(
// 3. 機台管理
Route::prefix('machines')->name('machines.')->group(function () {
Route::get('/logs', [App\Http\Controllers\Admin\MachineController::class , 'logs'])->name('logs');
Route::get('/permissions', [App\Http\Controllers\Admin\MachineController::class , 'permissions'])->name('permissions');
// Route::get('/permissions', [App\Http\Controllers\Admin\MachineController::class , 'permissions'])->name('permissions'); // Merged into Sub-account Management
Route::get('/permissions/accounts/{user}', [App\Http\Controllers\Admin\MachineController::class, 'getAccountMachines'])->name('permissions.accounts.get');
Route::post('/permissions/accounts/{user}', [App\Http\Controllers\Admin\MachineController::class, 'syncAccountMachines'])->name('permissions.accounts.sync');
Route::get('/utilization', [App\Http\Controllers\Admin\MachineController::class , 'utilization'])->name('utilization');
Route::get('/expiry', [App\Http\Controllers\Admin\MachineController::class , 'expiry'])->name('expiry');
Route::get('/maintenance', [App\Http\Controllers\Admin\MachineController::class , 'maintenance'])->name('maintenance');