feat: 實作使用者啟停用功能與安全性強化
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Has been skipped
Koori-ERP-Deploy-System / deploy-production (push) Successful in 1m1s

- 新增使用者「啟用/停用」狀態切換功能 (含後端 API、權限控管、活動紀錄)
- 強化安全性:隱藏超級管理員角色的可見度與操作權限
- 更新開發規範:加入多租戶資料同步規範於 framework.md
- 前端優化:使用 Switch 元件進行狀態快速切換,調整表格欄位順序
This commit is contained in:
2026-02-03 11:51:46 +08:00
parent 0185843c62
commit d671c08338
21 changed files with 350 additions and 161 deletions

View File

@@ -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`

View File

@@ -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', []);

View File

@@ -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}");
}
}

View File

@@ -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',
];
}

View File

@@ -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');
});

View File

@@ -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,
];
}

View File

@@ -0,0 +1,28 @@
<?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::table('users', function (Blueprint $table) {
$table->boolean('is_active')->default(true)->after('password');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn('is_active');
});
}
};

View File

@@ -0,0 +1,28 @@
<?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::table('users', function (Blueprint $table) {
$table->boolean('is_active')->default(true)->after('password');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn('is_active');
});
}
};

View File

@@ -23,6 +23,7 @@ class DatabaseSeeder extends Seeder
'name' => '系統管理員',
'email' => 'admin@example.com',
'password' => 'password',
'is_active' => true,
]
);

View File

@@ -75,6 +75,7 @@ class PermissionSeeder extends Seeder
'users.create',
'users.edit',
'users.delete',
'users.activate', // 啟用/停用使用者
// 角色權限管理
'roles.view',

View File

@@ -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`

View File

@@ -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: 建立房東後台
**手動操作**:無
---
## 其他注意事項
- 待補充...

30
package-lock.json generated
View File

@@ -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",

View File

@@ -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",

View File

@@ -340,9 +340,9 @@ export default function ActivityDetailDialog({ open, onOpenChange, activity }: P
.filter(key => attributes[key] !== null && attributes[key] !== '' && !isSnapshotField(key))
.map((key) => (
<TableRow key={key}>
<TableCell className="font-medium text-gray-700 w-[150px]">{getFieldLabel(key)}</TableCell>
<TableCell className="text-gray-500 break-words max-w-[200px]">-</TableCell>
<TableCell className="text-gray-900 font-medium break-words max-w-[200px]">
<TableCell className="font-medium text-gray-700 w-[120px] shrink-0">{getFieldLabel(key)}</TableCell>
<TableCell className="text-gray-500 break-all min-w-[150px]">-</TableCell>
<TableCell className="text-gray-900 font-medium break-all min-w-[200px] whitespace-pre-wrap">
{getFormattedValue(key, attributes[key])}
</TableCell>
</TableRow>
@@ -398,11 +398,11 @@ export default function ActivityDetailDialog({ open, onOpenChange, activity }: P
return (
<TableRow key={key} className={isChanged ? 'bg-amber-50/30 hover:bg-amber-50/50' : 'hover:bg-gray-50/50'}>
<TableCell className="font-medium text-gray-700 w-[150px]">{getFieldLabel(key)}</TableCell>
<TableCell className="text-gray-500 break-words max-w-[200px]">
<TableCell className="font-medium text-gray-700 w-[120px] shrink-0">{getFieldLabel(key)}</TableCell>
<TableCell className="text-gray-500 break-all min-w-[150px] whitespace-pre-wrap">
{displayBefore}
</TableCell>
<TableCell className="text-gray-900 font-medium break-words max-w-[200px]">
<TableCell className="text-gray-900 font-medium break-all min-w-[200px] whitespace-pre-wrap">
{displayAfter}
</TableCell>
</TableRow>

View File

@@ -173,16 +173,18 @@ export default function LogTable({
<TableCell>
<span className="font-medium text-gray-900">{activity.causer}</span>
</TableCell>
<TableCell>
<TableCell className="min-w-[300px]">
<div className="break-all">
{getDescription(activity)}
</div>
</TableCell>
<TableCell className="text-center">
<Badge variant="outline" className={getEventBadgeClass(activity.event)}>
{getEventLabel(activity.event)}
</Badge>
</TableCell>
<TableCell>
<Badge variant="outline" className="bg-slate-50 text-slate-600 border-slate-200">
<TableCell className="max-w-[200px]">
<Badge variant="outline" className="bg-slate-50 text-slate-600 border-slate-200 break-all whitespace-normal text-left h-auto py-1">
{activity.subject_type}
</Badge>
</TableCell>

View File

@@ -78,6 +78,7 @@ export default function RoleCreate({ groupedPermissions }: Props) {
'complete': '完成',
'view_cost': '檢視成本',
'view_logs': '檢視日誌',
'activate': '啟用/停用',
};
return map[action] || action;

View File

@@ -85,6 +85,7 @@ export default function RoleEdit({ role, groupedPermissions, currentPermissions
'complete': '完成',
'view_cost': '檢視成本',
'view_logs': '檢視日誌',
'activate': '啟用/停用',
};
return map[action] || action;

View File

@@ -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<string, string>; // 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 && <p className="text-sm text-red-500">{errors.email}</p>}
</div>
{/* Status */}
<Can permission="users.activate">
<div className="space-y-2">
<Label htmlFor="is_active" className="flex items-center gap-2">
<Check className="h-4 w-4" />
</Label>
<div className="flex items-center gap-2">
<Switch
id="is_active"
checked={data.is_active}
onCheckedChange={(checked: boolean) => setData('is_active', checked)}
/>
<span className="text-sm text-gray-500">
{data.is_active ? '啟用' : '停用'}
</span>
</div>
</div>
</Can>
</div>
{/* Roles */}

View File

@@ -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 && <p className="text-sm text-red-500">{errors.email}</p>}
</div>
{/* Status */}
<Can permission="users.activate">
<div className="space-y-2">
<Label htmlFor="is_active" className="flex items-center gap-2">
<Check className="h-4 w-4" />
</Label>
<div className="flex items-center gap-2">
<Switch
id="is_active"
checked={data.is_active}
onCheckedChange={(checked: boolean) => setData('is_active', checked)}
/>
<span className="text-sm text-gray-500">
{data.is_active ? '啟用' : '停用'}
</span>
</div>
<p className="text-xs text-gray-400">
使
</p>
</div>
</Can>
</div>
{/* Roles */}

View File

@@ -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<string>(filters.per_page || "10");
const [searchTerm, setSearchTerm] = useState(filters.search || "");
const [roleFilter, setRoleFilter] = useState<string>(filters.role || "all");
const [isActiveFilter, setIsActiveFilter] = useState<string>(filters.is_active || "all");
const [deleteId, setDeleteId] = useState<number | null>(null);
const [deleteName, setDeleteName] = useState<string>('');
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 <ArrowDown className="h-4 w-4 text-primary-main ml-1" />;
};
return (
<AuthenticatedLayout
breadcrumbs={[
@@ -223,6 +234,19 @@ export default function UserIndex({ users, roles, filters }: Props) {
className="w-full md:w-[180px]"
/>
{/* Status Filter */}
<SearchableSelect
value={isActiveFilter}
onValueChange={handleIsActiveChange}
options={[
{ label: "全部狀態", value: "all" },
{ label: "啟用", value: "1" },
{ label: "停用", value: "0" },
]}
placeholder="狀態篩選"
className="w-full md:w-[120px]"
/>
{/* Action Buttons */}
<div className="flex gap-2 w-full md:w-auto">
<Can permission="users.create">
@@ -251,6 +275,7 @@ export default function UserIndex({ users, roles, filters }: Props) {
</button>
</TableHead>
<TableHead></TableHead>
<TableHead className="w-[150px] text-center"></TableHead>
<TableHead>
<button
onClick={() => handleSort('created_at')}
@@ -308,6 +333,30 @@ export default function UserIndex({ users, roles, filters }: Props) {
)}
</div>
</TableCell>
<TableCell className="text-center">
<div className="flex items-center justify-center gap-3">
<div className={cn(
"inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium border whitespace-nowrap",
user.is_active
? "bg-green-50 text-green-700 border-green-200"
: "bg-gray-100 text-gray-700 border-gray-200"
)}>
<span className={cn(
"w-1.5 h-1.5 rounded-full mr-1.5",
user.is_active ? "bg-green-500" : "bg-gray-400"
)} />
{user.is_active ? '啟用' : '停用'}
</div>
<Can permission="users.activate">
<Switch
checked={user.is_active}
onCheckedChange={() => router.patch(route('users.toggle-active', user.id), {}, { preserveScroll: true })}
className="data-[state=checked]:bg-green-500"
/>
</Can>
</div>
</TableCell>
<TableCell className="text-gray-500 text-sm">
{format(new Date(user.created_at), 'yyyy/MM/dd')}
</TableCell>