diff --git a/.agent/rules/framework.md b/.agent/rules/framework.md index ca2cec8..eca2e04 100644 --- a/.agent/rules/framework.md +++ b/.agent/rules/framework.md @@ -67,7 +67,17 @@ trigger: always_on * 必須考慮 ERP 邏輯(例如:權限判斷、操作日誌、資料完整性)。 * 新增功能時,請先判斷應歸屬於哪個 Module,並建立在 `app/Modules/` 對應目錄下。 -## 8. 運行機制 (Docker / Sail) + * 新增功能時,請先判斷應歸屬於哪個 Module,並建立在 `app/Modules/` 對應目錄下。 + +## 8. 多租戶開發規範 (Multi-tenancy Standards) +本專案採用多租戶隔離架構,開發時必須遵守以下資料同步規則: +* **權限與選單同步**:新增 Permission 或修改系統設定時,必須確保中央資料庫 (Central) 與所有租戶資料庫 (Tenants) 均已同步。 +* **指令執行**: + * **Seeders**: 必須執行 `./vendor/bin/sail php artisan tenants:run db:seed` 以確保所有租戶均獲得更新。 + * **Tinker**: 檢查租戶資料時應使用 `./vendor/bin/sail php artisan tenants:run tinker`。 + * **Migrations**: 租戶相關的 Schema 異動應放在 `database/migrations/tenant/` 並執行 `./vendor/bin/sail artisan tenants:migrate`。 + +## 9. 運行機制 (Docker / Sail) 由於專案運行在 Docker 容器環境中,請勿直接在宿主機 (Host) 執行 php 或 composer 指令。請使用專案內建的 `sail` 指令: * **啟動環境**: `./vendor/bin/sail up -d` diff --git a/app/Modules/Core/Controllers/Auth/LoginController.php b/app/Modules/Core/Controllers/Auth/LoginController.php index d775c99..4e334cc 100644 --- a/app/Modules/Core/Controllers/Auth/LoginController.php +++ b/app/Modules/Core/Controllers/Auth/LoginController.php @@ -43,6 +43,17 @@ class LoginController extends Controller $credentials = $request->only('username', 'password'); if (Auth::attempt($credentials, $request->boolean('remember'))) { + // Check activation status + if (!Auth::user()->is_active) { + Auth::guard('web')->logout(); + $request->session()->invalidate(); + $request->session()->regenerateToken(); + + throw ValidationException::withMessages([ + 'username' => '此帳號已被停用,請聯繫管理員。', + ]); + } + $request->session()->regenerate(); $centralDomains = config('tenancy.central_domains', []); diff --git a/app/Modules/Core/Controllers/UserController.php b/app/Modules/Core/Controllers/UserController.php index e2cf0da..eafef39 100644 --- a/app/Modules/Core/Controllers/UserController.php +++ b/app/Modules/Core/Controllers/UserController.php @@ -22,9 +22,26 @@ class UserController extends Controller $sortBy = $request->input('sort_by', 'id'); $sortOrder = $request->input('sort_order', 'asc'); $search = $request->input('search'); - $roleId = $request->input('role'); - $query = User::with(['roles:id,name,display_name']); + $roleId = $request->input('role'); + $isActive = $request->input('is_active'); // 'all', '1', '0' + + $query = User::query(); + + // 隱藏超級管理員:若非 super-admin,則不可看到 super-admin 過往 + if (!auth()->user()->hasRole('super-admin')) { + $query->whereDoesntHave('roles', function ($q) { + $q->where('name', 'super-admin'); + }); + + // 預載入角色時也過濾掉 super-admin 標籤 + $query->with(['roles' => function ($q) { + $q->select('id', 'name', 'display_name') + ->where('name', '!=', 'super-admin'); + }]); + } else { + $query->with(['roles:id,name,display_name']); + } // 處理搜尋 if ($search) { @@ -42,6 +59,11 @@ class UserController extends Controller }); } + // 處理狀態篩選 + if ($isActive !== null && $isActive !== 'all') { + $query->where('is_active', $isActive === '1' || $isActive === 'true'); + } + // 處理排序 if (in_array($sortBy, ['name', 'created_at'])) { $query->orderBy($sortBy, $sortOrder); @@ -50,12 +72,19 @@ class UserController extends Controller } $users = $query->paginate($perPage)->withQueryString(); - $roles = Role::select('id', 'name', 'display_name')->get(); + + // 只能看到自己權限以下的角色 + $rolesQuery = Role::select('id', 'name', 'display_name'); + if (!auth()->user()->hasRole('super-admin')) { + $rolesQuery->where('name', '!=', 'super-admin'); + } + $roles = $rolesQuery->get(); return Inertia::render('Admin/User/Index', [ + 'users' => $users, 'users' => $users, 'roles' => $roles, - 'filters' => $request->only(['per_page', 'sort_by', 'sort_order', 'search', 'role']), + 'filters' => $request->only(['per_page', 'sort_by', 'sort_order', 'search', 'role', 'is_active']), ]); } @@ -64,7 +93,11 @@ class UserController extends Controller */ public function create() { - $roles = Role::pluck('display_name', 'name'); + $rolesQuery = Role::query(); + if (!auth()->user()->hasRole('super-admin')) { + $rolesQuery->where('name', '!=', 'super-admin'); + } + $roles = $rolesQuery->pluck('display_name', 'name'); return Inertia::render('Admin/User/Create', [ 'roles' => $roles @@ -80,8 +113,10 @@ class UserController extends Controller 'name' => ['required', 'string', 'max:255'], 'email' => ['nullable', 'string', 'email', 'max:255', 'unique:users'], 'username' => ['required', 'string', 'max:255', 'unique:users'], + 'password' => ['required', 'string', 'min:8', 'confirmed'], 'roles' => ['array'], + 'is_active' => ['boolean'], ], [ 'password.required' => '請輸入密碼', 'password.min' => '密碼長度至少需 :min 個字元', @@ -92,10 +127,16 @@ class UserController extends Controller 'name' => $validated['name'], 'email' => $validated['email'], 'username' => $validated['username'], + 'password' => Hash::make($validated['password']), + 'is_active' => $request->boolean('is_active', true), ]); if (!empty($validated['roles'])) { + // 安全檢查:非 super-admin 不能賦予 super-admin 角色 + if (!auth()->user()->hasRole('super-admin') && in_array('super-admin', $validated['roles'])) { + abort(403, '您沒有權限指派系統管理員角色'); + } $user->syncRoles($validated['roles']); // 更新 'created' 紀錄以包含角色資訊 @@ -123,7 +164,17 @@ class UserController extends Controller public function edit(string $id) { $user = User::with('roles')->findOrFail($id); - $roles = Role::get(['id', 'name', 'display_name']); + + // 安全檢查:非 super-admin 不能編輯 super-admin + if ($user->hasRole('super-admin') && !auth()->user()->hasRole('super-admin')) { + abort(403, '您沒有權限編輯系統管理員'); + } + + $rolesQuery = Role::select('id', 'name', 'display_name'); + if (!auth()->user()->hasRole('super-admin')) { + $rolesQuery->where('name', '!=', 'super-admin'); + } + $roles = $rolesQuery->get(); return Inertia::render('Admin/User/Edit', [ 'user' => $user, @@ -139,12 +190,19 @@ class UserController extends Controller { $user = User::findOrFail($id); + // 安全檢查:非 super-admin 不能更新 super-admin + if ($user->hasRole('super-admin') && !auth()->user()->hasRole('super-admin')) { + abort(403, '您沒有權限編輯系統管理員'); + } + $validated = $request->validate([ 'name' => ['required', 'string', 'max:255'], 'email' => ['nullable', 'string', 'email', 'max:255', Rule::unique('users')->ignore($user->id)], 'username' => ['required', 'string', 'max:255', Rule::unique('users')->ignore($user->id)], + 'password' => ['nullable', 'string', 'min:8', 'confirmed'], 'roles' => ['array'], + 'is_active' => ['boolean'], ], [ 'password.min' => '密碼長度至少需 :min 個字元', 'password.confirmed' => '密碼確認不符', @@ -157,10 +215,6 @@ class UserController extends Controller 'username' => $validated['username'], ]; - if (!empty($validated['password'])) { - $userData['password'] = Hash::make($validated['password']); - } - $user->fill($userData); // 捕捉變更屬性以進行手動記錄 @@ -179,6 +233,11 @@ class UserController extends Controller // 2. 處理角色 $roleChanges = null; if (isset($validated['roles'])) { + // 安全檢查:非 super-admin 不能賦予 super-admin 角色 + if (!auth()->user()->hasRole('super-admin') && in_array('super-admin', $validated['roles'])) { + abort(403, '您沒有權限指派系統管理員角色'); + } + $oldRoles = $user->roles()->pluck('display_name')->join(', '); $user->syncRoles($validated['roles']); $newRoles = $user->roles()->pluck('display_name')->join(', '); @@ -230,6 +289,11 @@ class UserController extends Controller { $user = User::findOrFail($id); + // 安全檢查:非 super-admin 不能刪除 super-admin + if ($user->hasRole('super-admin') && !auth()->user()->hasRole('super-admin')) { + abort(403, '您沒有權限刪除系統管理員'); + } + if ($user->hasRole('super-admin')) { return back()->with('error', '無法刪除超級管理員帳號'); } @@ -240,6 +304,46 @@ class UserController extends Controller $user->delete(); - return redirect()->route('users.index')->with('success', '使用者已刪除'); + return redirect()->route('users.index')->with('success', "使用者「{$user->name}」已刪除"); + } + + /** + * 切換使用者啟用/停用狀態 + */ + public function toggleActive(string $id) + { + $user = User::findOrFail($id); + + // 安全檢查:不能停用自己 + if ($user->id === auth()->id() && $user->is_active) { + return back()->with('error', '無法停用自己的帳號'); + } + + // 安全檢查:非 super-admin 不能停用 super-admin + if ($user->hasRole('super-admin') && !auth()->user()->hasRole('super-admin')) { + abort(403, '您沒有權限變更系統管理員狀態'); + } + + $oldStatus = $user->is_active; + $user->is_active = !$oldStatus; + $user->save(); + + // 記錄活動 + activity() + ->performedOn($user) + ->causedBy(auth()->user()) + ->event('updated') + ->withProperties([ + 'attributes' => ['is_active' => $user->is_active], + 'old' => ['is_active' => $oldStatus], + 'snapshot' => [ + 'name' => $user->name, + 'username' => $user->username, + ] + ]) + ->log('updated'); + + $statusText = $user->is_active ? '已啟用' : '已停用'; + return back()->with('success', "使用者「{$user->name}」{$statusText}"); } } diff --git a/app/Modules/Core/Models/User.php b/app/Modules/Core/Models/User.php index d7f15ce..7ac0e11 100644 --- a/app/Modules/Core/Models/User.php +++ b/app/Modules/Core/Models/User.php @@ -35,6 +35,7 @@ class User extends Authenticatable 'email', 'username', 'password', + 'is_active', ]; /** @@ -56,7 +57,9 @@ class User extends Authenticatable { return [ 'email_verified_at' => 'datetime', + 'password' => 'hashed', + 'is_active' => 'boolean', ]; } diff --git a/app/Modules/Core/Routes/web.php b/app/Modules/Core/Routes/web.php index 183a538..66f55af 100644 --- a/app/Modules/Core/Routes/web.php +++ b/app/Modules/Core/Routes/web.php @@ -43,6 +43,7 @@ Route::middleware('auth')->group(function () { }); Route::get('/users/{user}/edit', [UserController::class, 'edit'])->middleware('permission:users.edit')->name('users.edit'); Route::put('/users/{user}', [UserController::class, 'update'])->middleware('permission:users.edit')->name('users.update'); + Route::patch('/users/{user}/toggle-active', [UserController::class, 'toggleActive'])->middleware('permission:users.activate')->name('users.toggle-active'); Route::delete('/users/{user}', [UserController::class, 'destroy'])->middleware('permission:users.delete')->name('users.destroy'); }); diff --git a/database/factories/UserFactory.php b/database/factories/UserFactory.php index f053204..ea847a6 100644 --- a/database/factories/UserFactory.php +++ b/database/factories/UserFactory.php @@ -37,6 +37,7 @@ class UserFactory extends Factory 'email_verified_at' => now(), 'password' => static::$password ??= Hash::make('password'), 'remember_token' => Str::random(10), + 'is_active' => true, ]; } diff --git a/database/migrations/2026_02_03_100812_add_is_active_to_users_table.php b/database/migrations/2026_02_03_100812_add_is_active_to_users_table.php new file mode 100644 index 0000000..b2fa208 --- /dev/null +++ b/database/migrations/2026_02_03_100812_add_is_active_to_users_table.php @@ -0,0 +1,28 @@ +boolean('is_active')->default(true)->after('password'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropColumn('is_active'); + }); + } +}; diff --git a/database/migrations/tenant/2026_02_03_103700_add_is_active_to_users_table.php b/database/migrations/tenant/2026_02_03_103700_add_is_active_to_users_table.php new file mode 100644 index 0000000..b2fa208 --- /dev/null +++ b/database/migrations/tenant/2026_02_03_103700_add_is_active_to_users_table.php @@ -0,0 +1,28 @@ +boolean('is_active')->default(true)->after('password'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropColumn('is_active'); + }); + } +}; diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index 56e17a8..1a40011 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -23,6 +23,7 @@ class DatabaseSeeder extends Seeder 'name' => '系統管理員', 'email' => 'admin@example.com', 'password' => 'password', + 'is_active' => true, ] ); diff --git a/database/seeders/PermissionSeeder.php b/database/seeders/PermissionSeeder.php index e8beb24..f4b056f 100644 --- a/database/seeders/PermissionSeeder.php +++ b/database/seeders/PermissionSeeder.php @@ -75,6 +75,7 @@ class PermissionSeeder extends Seeder 'users.create', 'users.edit', 'users.delete', + 'users.activate', // 啟用/停用使用者 // 角色權限管理 'roles.view', diff --git a/docs/FRAMEWORK_SPEC.md b/docs/FRAMEWORK_SPEC.md deleted file mode 100644 index 53c0ce9..0000000 --- a/docs/FRAMEWORK_SPEC.md +++ /dev/null @@ -1,60 +0,0 @@ -# 開發框架規範說明書:ERP 系統 (star-erp) - -## 1. 專案概述 -* **目標**: 打造一個強大且穩定的 ERP 後台管理系統。 -* **核心架構**: 採用 **模組化單體式架構 (Modular Monolith)** 配現代化前端。使用 Laravel、Inertia.js 及 React。 -* **工作流程**: 將 UI/UX 設計師提供的 React 原始碼,透過 Inertia.js 整合進 Laravel 環境中。後端邏輯依據「業務領域」拆分為獨立模組。 - -## 2. 技術棧 (Tech Stack) -* **後端**: PHP 8.5 / Laravel 12 -* **前端橋樑**: Inertia.js (不使用傳統 RESTful API 串接頁面,改用 Inertia 協議) -* **前端庫**: React (以 Functional Components 與 Hooks 為主) -* **樣式處理**: Tailwind CSS (確保與 UI/UX 設計稿完全一致) -* **資料庫**: MySQL 8.0 -* **開發環境**: Laravel Sail (Docker / WSL2) -* **未來擴充**: 針對高併發或跨平台模組,預留 Golang 微服務接口。 - -## 3. 目錄結構與慣例 - -### 3.1 後端 (Laravel - Modular Monolith) -系統採用模組化架構,核心邏輯位於 `app/Modules/` 下: - -* **Modules**: 位於 `app/Modules/{ModuleName}/`。 - * **Controllers**: `app/Modules/{ModuleName}/Controllers/`。必須回傳 `Inertia::render()`。 - * **Models**: `app/Modules/{ModuleName}/Models/`。 - * **Routes**: `app/Modules/{ModuleName}/Routes/web.php`。各模組獨立管理路由。 -* **Global Routes**: `routes/web.php` 僅保留全域通用路由或作為模組路由的載入點。 - -### 3.2 前端 (React) -* **Pages (頁面)**: 位於 `resources/js/Pages/`。每個檔案代表一個完整的路由視圖。 -* **Components (組件)**: 位於 `resources/js/Components/`。存放由 UI/UX 團隊提供的可重複使用 UI 元件。 -* **Layouts (版面)**: 位於 `resources/js/Layouts/`。定義 ERP 的通用版面。 - -## 4. 整合指南 (UI/UX 轉換至 Laravel) -* **組件遷移**: 將 UI/UX 的 React 原始碼移入 `resources/js/` 時,應進行「原子化」拆解,提高元件複用率。 -* **資料傳遞**: 透過 Laravel Controller 的 props 傳送動態資料給 React。優先使用 Inertia 資料流,避免初次渲染時使用 axios。 -* **狀態管理**: 優先使用 Inertia 內建的狀態管理與 useForm hook 處理表單提交。 - -## 5. 開發標準 (Coding Standards) -* **命名規範**: - * Controllers: `PascalCaseController.php` - * React Components: `PascalCase.jsx` - * Routes: `kebab-case` (小寫橫線分隔) -* **回傳格式**: 所有的前後端溝通需維持一致的 JSON 結構,特別是驗證錯誤 (Validation Errors) 與閃存訊息 (Flash Messages)。 - -## 6. AI 協作規則 (給 Antigravity AI) -* **角色設定**: 你是一位專業的全端開發工程師助手。 -* **代碼生成指令**: - * 所有的解釋說明請使用 **繁體中文**。 - * 生成 React 組件時,必須符合專案現有的 Tailwind CSS 配置。 - * 必須考慮 ERP 邏輯(例如:權限判斷、操作日誌、資料完整性)。 - * 新增功能時,請先判斷應歸屬於哪個 Module,並建立在 `app/Modules/` 對應目錄下。 - -## 7. 運行機制 (Docker / Sail) -由於專案運行在 Docker 容器環境中,請勿直接在宿主機 (Host) 執行 php 或 composer 指令。請使用專案內建的 `sail` 指令: - -* **啟動環境**: `./vendor/bin/sail up -d` -* **執行 PHP 指令**: `./vendor/bin/sail php -v` -* **執行 Artisan 指令**: `./vendor/bin/sail artisan route:list` -* **執行 Composer**: `./vendor/bin/sail composer install` -* **執行 Node/NPM**: `./vendor/bin/sail npm run dev` \ No newline at end of file diff --git a/docs/multi-tenancy-deployment.md b/docs/multi-tenancy-deployment.md deleted file mode 100644 index a86e49e..0000000 --- a/docs/multi-tenancy-deployment.md +++ /dev/null @@ -1,71 +0,0 @@ -# Multi-tenancy 部署手冊 - -> 記錄本地開發完成後,上 Demo/Production 環境時需要手動執行的操作。 -> CI/CD 會自動執行的項目已排除。 - ---- - -## Step 1: 安裝 stancl/tenancy -**CI/CD 會自動執行**:`composer install` -**手動操作**:無 - ---- - -## Step 2: 設定 Central Domain + Tenant 識別 -**手動操作**: -1. 修改 `.env`,加入: - ```bash - # Demo 環境 (192.168.0.103) - CENTRAL_DOMAINS=192.168.0.103,localhost - - # Production 環境 (erp.koori.tw) - CENTRAL_DOMAINS=erp.koori.tw - ``` - ---- - -## Step 3: 分離 Migrations -**CI/CD 會自動執行**:`php artisan migrate --force` -**手動操作**:無 - -> 注意:migrations 結構已調整如下: -> - `database/migrations/` - Central tables (tenants, domains) -> - `database/migrations/tenant/` - Tenant tables (所有業務表) - ---- - -## Step 4: 遷移現有資料到 tenant_koori -**首次部署手動操作**: -1. 授予 MySQL sail 使用者 CREATE DATABASE 權限: - ```bash - docker exec koori-erp-mysql mysql -uroot -p[PASSWORD] -e "GRANT ALL PRIVILEGES ON *.* TO 'sail'@'%' WITH GRANT OPTION; FLUSH PRIVILEGES;" - ``` - -2. 建立第一個租戶 (小小冰室): - ```bash - docker exec -w /var/www/html koori-erp-laravel php artisan tinker --execute=" - use App\Models\Tenant; - Tenant::create(['id' => 'koori', 'name' => '小小冰室']); - " - ``` - -3. 為租戶綁定域名: - ```bash - docker exec -w /var/www/html koori-erp-laravel php artisan tinker --execute=" - use App\Models\Tenant; - Tenant::find('koori')->domains()->create(['domain' => 'koori.your-domain.com']); - " - ``` - -4. 執行資料遷移 (從 central DB 複製到 tenant DB): - ```bash - docker exec -w /var/www/html koori-erp-laravel php artisan tenancy:migrate-data koori - ``` - -## Step 5: 建立房東後台 -**手動操作**:無 - ---- - -## 其他注意事項 -- 待補充... diff --git a/package-lock.json b/package-lock.json index 625d796..ac5615d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,6 +20,7 @@ "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-slot": "^1.2.4", + "@radix-ui/react-switch": "^1.2.6", "@radix-ui/react-tooltip": "^1.2.8", "@types/lodash": "^4.17.21", "@vitejs/plugin-react": "^5.1.2", @@ -1768,6 +1769,35 @@ } } }, + "node_modules/@radix-ui/react-switch": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-switch/-/react-switch-1.2.6.tgz", + "integrity": "sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-use-size": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-tooltip": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.8.tgz", diff --git a/package.json b/package.json index 0c7d346..b26d1ae 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-slot": "^1.2.4", + "@radix-ui/react-switch": "^1.2.6", "@radix-ui/react-tooltip": "^1.2.8", "@types/lodash": "^4.17.21", "@vitejs/plugin-react": "^5.1.2", diff --git a/resources/js/Components/ActivityLog/ActivityDetailDialog.tsx b/resources/js/Components/ActivityLog/ActivityDetailDialog.tsx index 0137512..c0059e5 100644 --- a/resources/js/Components/ActivityLog/ActivityDetailDialog.tsx +++ b/resources/js/Components/ActivityLog/ActivityDetailDialog.tsx @@ -340,9 +340,9 @@ export default function ActivityDetailDialog({ open, onOpenChange, activity }: P .filter(key => attributes[key] !== null && attributes[key] !== '' && !isSnapshotField(key)) .map((key) => ( - {getFieldLabel(key)} - - - + {getFieldLabel(key)} + - + {getFormattedValue(key, attributes[key])} @@ -398,11 +398,11 @@ export default function ActivityDetailDialog({ open, onOpenChange, activity }: P return ( - {getFieldLabel(key)} - + {getFieldLabel(key)} + {displayBefore} - + {displayAfter} diff --git a/resources/js/Components/ActivityLog/LogTable.tsx b/resources/js/Components/ActivityLog/LogTable.tsx index 7ed9652..51fd866 100644 --- a/resources/js/Components/ActivityLog/LogTable.tsx +++ b/resources/js/Components/ActivityLog/LogTable.tsx @@ -173,16 +173,18 @@ export default function LogTable({ {activity.causer} - - {getDescription(activity)} + +
+ {getDescription(activity)} +
{getEventLabel(activity.event)} - - + + {activity.subject_type} diff --git a/resources/js/Pages/Admin/Role/Create.tsx b/resources/js/Pages/Admin/Role/Create.tsx index 34f86ca..f0ea561 100644 --- a/resources/js/Pages/Admin/Role/Create.tsx +++ b/resources/js/Pages/Admin/Role/Create.tsx @@ -78,6 +78,7 @@ export default function RoleCreate({ groupedPermissions }: Props) { 'complete': '完成', 'view_cost': '檢視成本', 'view_logs': '檢視日誌', + 'activate': '啟用/停用', }; return map[action] || action; diff --git a/resources/js/Pages/Admin/Role/Edit.tsx b/resources/js/Pages/Admin/Role/Edit.tsx index 649e216..a5c8261 100644 --- a/resources/js/Pages/Admin/Role/Edit.tsx +++ b/resources/js/Pages/Admin/Role/Edit.tsx @@ -85,6 +85,7 @@ export default function RoleEdit({ role, groupedPermissions, currentPermissions 'complete': '完成', 'view_cost': '檢視成本', 'view_logs': '檢視日誌', + 'activate': '啟用/停用', }; return map[action] || action; diff --git a/resources/js/Pages/Admin/User/Create.tsx b/resources/js/Pages/Admin/User/Create.tsx index 699a66c..fd11602 100644 --- a/resources/js/Pages/Admin/User/Create.tsx +++ b/resources/js/Pages/Admin/User/Create.tsx @@ -6,6 +6,8 @@ import { Input } from '@/Components/ui/input'; import { Label } from '@/Components/ui/label'; import { RadioGroup, RadioGroupItem } from '@/Components/ui/radio-group'; import { FormEvent } from 'react'; +import { Switch } from '@/Components/ui/switch'; +import { Can } from '@/Components/Permission/Can'; interface Props { roles: Record; // Name (ID) -> DisplayName map from pluck @@ -19,6 +21,7 @@ export default function UserCreate({ roles }: Props) { password: '', password_confirmation: '', roles: [] as string[], // Role names + is_active: true, }); const handleSubmit = (e: FormEvent) => { @@ -119,6 +122,25 @@ export default function UserCreate({ roles }: Props) { /> {errors.email &&

{errors.email}

} + + {/* Status */} + +
+ +
+ setData('is_active', checked)} + /> + + {data.is_active ? '啟用' : '停用'} + +
+
+
{/* Roles */} diff --git a/resources/js/Pages/Admin/User/Edit.tsx b/resources/js/Pages/Admin/User/Edit.tsx index a658498..48a891d 100644 --- a/resources/js/Pages/Admin/User/Edit.tsx +++ b/resources/js/Pages/Admin/User/Edit.tsx @@ -6,6 +6,8 @@ import { Input } from '@/Components/ui/input'; import { Label } from '@/Components/ui/label'; import { RadioGroup, RadioGroupItem } from '@/Components/ui/radio-group'; import { FormEvent } from 'react'; +import { Switch } from '@/Components/ui/switch'; +import { Can } from '@/Components/Permission/Can'; interface Role { id: number; @@ -18,6 +20,7 @@ interface UserData { name: string; email: string; username: string | null; + is_active: boolean; } interface Props { @@ -34,6 +37,7 @@ export default function UserEdit({ user, roles, currentRoles }: Props) { password: '', password_confirmation: '', roles: currentRoles, + is_active: user.is_active, }); const handleSubmit = (e: FormEvent) => { @@ -133,6 +137,28 @@ export default function UserEdit({ user, roles, currentRoles }: Props) { /> {errors.email &&

{errors.email}

} + + {/* Status */} + +
+ +
+ setData('is_active', checked)} + /> + + {data.is_active ? '啟用' : '停用'} + +
+

+ 停用後該使用者將無法登入系統 +

+
+
{/* Roles */} diff --git a/resources/js/Pages/Admin/User/Index.tsx b/resources/js/Pages/Admin/User/Index.tsx index d1664fe..241acf1 100644 --- a/resources/js/Pages/Admin/User/Index.tsx +++ b/resources/js/Pages/Admin/User/Index.tsx @@ -18,6 +18,7 @@ import { Can } from '@/Components/Permission/Can'; import { cn } from "@/lib/utils"; import Pagination from "@/Components/shared/Pagination"; import { SearchableSelect } from "@/Components/ui/searchable-select"; +import { Switch } from "@/Components/ui/switch"; import { AlertDialog, AlertDialogAction, @@ -42,6 +43,7 @@ interface User { username: string | null; created_at: string; roles: Role[]; + is_active: boolean; } interface PaginationLinks { @@ -62,6 +64,7 @@ interface Props { sort_order?: 'asc' | 'desc'; search?: string; role?: string; + is_active?: string; }; roles: Role[]; } @@ -70,6 +73,7 @@ export default function UserIndex({ users, roles, filters }: Props) { const [perPage, setPerPage] = useState(filters.per_page || "10"); const [searchTerm, setSearchTerm] = useState(filters.search || ""); const [roleFilter, setRoleFilter] = useState(filters.role || "all"); + const [isActiveFilter, setIsActiveFilter] = useState(filters.is_active || "all"); const [deleteId, setDeleteId] = useState(null); const [deleteName, setDeleteName] = useState(''); const [modelOpen, setModelOpen] = useState(false); @@ -95,17 +99,17 @@ export default function UserIndex({ users, roles, filters }: Props) { setPerPage(value); router.get( route('users.index'), - { ...filters, per_page: value, search: searchTerm, role: roleFilter }, + { ...filters, per_page: value, search: searchTerm, role: roleFilter, is_active: isActiveFilter }, { preserveState: false, replace: true, preserveScroll: true } ); }; // Debounced Search Handler const debouncedSearch = useCallback( - debounce((term: string, role: string) => { + debounce((term: string, role: string, isActive: string) => { router.get( route('users.index'), - { ...filters, search: term, role: role }, + { ...filters, search: term, role: role, is_active: isActive }, { preserveState: true, replace: true, preserveScroll: true } ); }, 500), @@ -114,14 +118,23 @@ export default function UserIndex({ users, roles, filters }: Props) { const handleSearchChange = (term: string) => { setSearchTerm(term); - debouncedSearch(term, roleFilter); + debouncedSearch(term, roleFilter, isActiveFilter); }; const handleRoleChange = (value: string) => { setRoleFilter(value); router.get( route('users.index'), - { ...filters, search: searchTerm, role: value }, + { ...filters, search: searchTerm, role: value, is_active: isActiveFilter }, + { preserveState: false, replace: true, preserveScroll: true } + ); + }; + + const handleIsActiveChange = (value: string) => { + setIsActiveFilter(value); + router.get( + route('users.index'), + { ...filters, search: searchTerm, role: roleFilter, is_active: value }, { preserveState: false, replace: true, preserveScroll: true } ); }; @@ -130,7 +143,7 @@ export default function UserIndex({ users, roles, filters }: Props) { setSearchTerm(""); router.get( route('users.index'), - { ...filters, search: "", role: roleFilter }, + { ...filters, search: "", role: roleFilter, is_active: isActiveFilter }, { preserveState: true, replace: true, preserveScroll: true } ); }; @@ -165,8 +178,6 @@ export default function UserIndex({ users, roles, filters }: Props) { return ; }; - - return ( + {/* Status Filter */} + + {/* Action Buttons */}
@@ -251,6 +275,7 @@ export default function UserIndex({ users, roles, filters }: Props) { 角色 + 狀態
+ +
+
+ + {user.is_active ? '啟用' : '停用'} +
+ + + router.patch(route('users.toggle-active', user.id), {}, { preserveScroll: true })} + className="data-[state=checked]:bg-green-500" + /> + +
+
{format(new Date(user.created_at), 'yyyy/MM/dd')}