feat: 完成權限管理系統、統一頁面標題樣式與表格對齊規範
This commit is contained in:
170
app/Http/Controllers/Admin/RoleController.php
Normal file
170
app/Http/Controllers/Admin/RoleController.php
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Admin;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Spatie\Permission\Models\Role;
|
||||||
|
use Spatie\Permission\Models\Permission;
|
||||||
|
use Inertia\Inertia;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
|
class RoleController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Display a listing of the resource.
|
||||||
|
*/
|
||||||
|
public function index()
|
||||||
|
{
|
||||||
|
$roles = Role::withCount('users', 'permissions')
|
||||||
|
->orderBy('id')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
return Inertia::render('Admin/Role/Index', [
|
||||||
|
'roles' => $roles
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show the form for creating a new resource.
|
||||||
|
*/
|
||||||
|
public function create()
|
||||||
|
{
|
||||||
|
$permissions = $this->getGroupedPermissions();
|
||||||
|
|
||||||
|
return Inertia::render('Admin/Role/Create', [
|
||||||
|
'groupedPermissions' => $permissions
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store a newly created resource in storage.
|
||||||
|
*/
|
||||||
|
public function store(Request $request)
|
||||||
|
{
|
||||||
|
$validated = $request->validate([
|
||||||
|
'name' => ['required', 'string', 'max:255', 'unique:roles,name'],
|
||||||
|
'permissions' => ['array'],
|
||||||
|
'permissions.*' => ['exists:permissions,name']
|
||||||
|
]);
|
||||||
|
|
||||||
|
$role = Role::create(['name' => $validated['name']]);
|
||||||
|
|
||||||
|
if (!empty($validated['permissions'])) {
|
||||||
|
$role->syncPermissions($validated['permissions']);
|
||||||
|
}
|
||||||
|
|
||||||
|
return redirect()->route('roles.index')->with('success', '角色建立成功');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show the form for editing the specified resource.
|
||||||
|
*/
|
||||||
|
public function edit(string $id)
|
||||||
|
{
|
||||||
|
$role = Role::with('permissions')->findOrFail($id);
|
||||||
|
|
||||||
|
// 禁止編輯超級管理員角色
|
||||||
|
if ($role->name === 'super-admin') {
|
||||||
|
return redirect()->route('roles.index')->with('error', '超級管理員角色不可編輯');
|
||||||
|
}
|
||||||
|
|
||||||
|
$groupedPermissions = $this->getGroupedPermissions();
|
||||||
|
$currentPermissions = $role->permissions->pluck('name')->toArray();
|
||||||
|
|
||||||
|
return Inertia::render('Admin/Role/Edit', [
|
||||||
|
'role' => $role,
|
||||||
|
'groupedPermissions' => $groupedPermissions,
|
||||||
|
'currentPermissions' => $currentPermissions
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the specified resource in storage.
|
||||||
|
*/
|
||||||
|
public function update(Request $request, string $id)
|
||||||
|
{
|
||||||
|
$role = Role::findOrFail($id);
|
||||||
|
|
||||||
|
if ($role->name === 'super-admin') {
|
||||||
|
return redirect()->route('roles.index')->with('error', '超級管理員角色不可變更');
|
||||||
|
}
|
||||||
|
|
||||||
|
$validated = $request->validate([
|
||||||
|
'name' => ['required', 'string', 'max:255', Rule::unique('roles', 'name')->ignore($role->id)],
|
||||||
|
'permissions' => ['array'],
|
||||||
|
'permissions.*' => ['exists:permissions,name']
|
||||||
|
]);
|
||||||
|
|
||||||
|
$role->update(['name' => $validated['name']]);
|
||||||
|
|
||||||
|
if (isset($validated['permissions'])) {
|
||||||
|
$role->syncPermissions($validated['permissions']);
|
||||||
|
}
|
||||||
|
|
||||||
|
return redirect()->route('roles.index')->with('success', '角色更新成功');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove the specified resource from storage.
|
||||||
|
*/
|
||||||
|
public function destroy(string $id)
|
||||||
|
{
|
||||||
|
$role = Role::withCount('users')->findOrFail($id);
|
||||||
|
|
||||||
|
if ($role->name === 'super-admin') {
|
||||||
|
return back()->with('error', '超級管理員角色不可刪除');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($role->users_count > 0) {
|
||||||
|
return back()->with('error', "尚有 {$role->users_count} 位使用者屬於此角色,無法刪除");
|
||||||
|
}
|
||||||
|
|
||||||
|
$role->delete();
|
||||||
|
|
||||||
|
return redirect()->route('roles.index')->with('success', '角色已刪除');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取得並分組權限
|
||||||
|
*/
|
||||||
|
private function getGroupedPermissions()
|
||||||
|
{
|
||||||
|
$allPermissions = Permission::orderBy('name')->get();
|
||||||
|
$grouped = [];
|
||||||
|
|
||||||
|
foreach ($allPermissions as $permission) {
|
||||||
|
// 假設命名格式為 group.action (例如 products.create)
|
||||||
|
$parts = explode('.', $permission->name);
|
||||||
|
$group = $parts[0];
|
||||||
|
|
||||||
|
if (!isset($grouped[$group])) {
|
||||||
|
$grouped[$group] = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$grouped[$group][] = $permission;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 翻譯群組名稱 (可選,優化顯示)
|
||||||
|
$groupNames = [
|
||||||
|
'products' => '商品資料管理',
|
||||||
|
'vendors' => '廠商資料管理',
|
||||||
|
'purchase_orders' => '採購單管理',
|
||||||
|
'warehouses' => '倉庫管理',
|
||||||
|
'inventory' => '庫存管理',
|
||||||
|
'users' => '使用者管理',
|
||||||
|
'roles' => '角色權限管理',
|
||||||
|
];
|
||||||
|
|
||||||
|
$result = [];
|
||||||
|
foreach ($grouped as $key => $permissions) {
|
||||||
|
$result[] = [
|
||||||
|
'key' => $key,
|
||||||
|
'name' => $groupNames[$key] ?? ucfirst($key),
|
||||||
|
'permissions' => $permissions
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
}
|
||||||
136
app/Http/Controllers/Admin/UserController.php
Normal file
136
app/Http/Controllers/Admin/UserController.php
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Admin;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Spatie\Permission\Models\Role;
|
||||||
|
use Inertia\Inertia;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
use Illuminate\Support\Facades\Hash;
|
||||||
|
|
||||||
|
class UserController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Display a listing of the resource.
|
||||||
|
*/
|
||||||
|
public function index()
|
||||||
|
{
|
||||||
|
$users = User::with('roles')
|
||||||
|
->orderBy('id')
|
||||||
|
->paginate(10); // 分頁
|
||||||
|
|
||||||
|
return Inertia::render('Admin/User/Index', [
|
||||||
|
'users' => $users
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show the form for creating a new resource.
|
||||||
|
*/
|
||||||
|
public function create()
|
||||||
|
{
|
||||||
|
$roles = Role::pluck('name', 'id');
|
||||||
|
|
||||||
|
return Inertia::render('Admin/User/Create', [
|
||||||
|
'roles' => $roles
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store a newly created resource in storage.
|
||||||
|
*/
|
||||||
|
public function store(Request $request)
|
||||||
|
{
|
||||||
|
$validated = $request->validate([
|
||||||
|
'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'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$user = User::create([
|
||||||
|
'name' => $validated['name'],
|
||||||
|
'email' => $validated['email'],
|
||||||
|
'username' => $validated['username'],
|
||||||
|
'password' => Hash::make($validated['password']),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!empty($validated['roles'])) {
|
||||||
|
$user->syncRoles($validated['roles']);
|
||||||
|
}
|
||||||
|
|
||||||
|
return redirect()->route('users.index')->with('success', '使用者建立成功');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show the form for editing the specified resource.
|
||||||
|
*/
|
||||||
|
public function edit(string $id)
|
||||||
|
{
|
||||||
|
$user = User::with('roles')->findOrFail($id);
|
||||||
|
$roles = Role::get(['id', 'name']);
|
||||||
|
|
||||||
|
return Inertia::render('Admin/User/Edit', [
|
||||||
|
'user' => $user,
|
||||||
|
'roles' => $roles,
|
||||||
|
'currentRoles' => $user->getRoleNames()
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the specified resource in storage.
|
||||||
|
*/
|
||||||
|
public function update(Request $request, string $id)
|
||||||
|
{
|
||||||
|
$user = User::findOrFail($id);
|
||||||
|
|
||||||
|
$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'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$userData = [
|
||||||
|
'name' => $validated['name'],
|
||||||
|
'email' => $validated['email'],
|
||||||
|
'username' => $validated['username'],
|
||||||
|
];
|
||||||
|
|
||||||
|
if (!empty($validated['password'])) {
|
||||||
|
$userData['password'] = Hash::make($validated['password']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$user->update($userData);
|
||||||
|
|
||||||
|
if (isset($validated['roles'])) {
|
||||||
|
$user->syncRoles($validated['roles']);
|
||||||
|
}
|
||||||
|
|
||||||
|
return redirect()->route('users.index')->with('success', '使用者更新成功');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove the specified resource from storage.
|
||||||
|
*/
|
||||||
|
public function destroy(string $id)
|
||||||
|
{
|
||||||
|
$user = User::findOrFail($id);
|
||||||
|
|
||||||
|
if ($user->hasRole('super-admin')) {
|
||||||
|
return back()->with('error', '無法刪除超級管理員帳號');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($user->id === auth()->id()) {
|
||||||
|
return back()->with('error', '無法刪除自己');
|
||||||
|
}
|
||||||
|
|
||||||
|
$user->delete();
|
||||||
|
|
||||||
|
return redirect()->route('users.index')->with('success', '使用者已刪除');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -35,10 +35,20 @@ class HandleInertiaRequests extends Middleware
|
|||||||
*/
|
*/
|
||||||
public function share(Request $request): array
|
public function share(Request $request): array
|
||||||
{
|
{
|
||||||
|
$user = $request->user();
|
||||||
|
|
||||||
return [
|
return [
|
||||||
...parent::share($request),
|
...parent::share($request),
|
||||||
'auth' => [
|
'auth' => [
|
||||||
'user' => $request->user(),
|
'user' => $user ? [
|
||||||
|
'id' => $user->id,
|
||||||
|
'name' => $user->name,
|
||||||
|
'email' => $user->email,
|
||||||
|
'username' => $user->username ?? null,
|
||||||
|
// 權限資料
|
||||||
|
'roles' => $user->getRoleNames(),
|
||||||
|
'permissions' => $user->getAllPermissions()->pluck('name')->toArray(),
|
||||||
|
] : null,
|
||||||
],
|
],
|
||||||
'flash' => [
|
'flash' => [
|
||||||
'success' => $request->session()->get('success'),
|
'success' => $request->session()->get('success'),
|
||||||
|
|||||||
@@ -6,11 +6,12 @@ namespace App\Models;
|
|||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||||
use Illuminate\Notifications\Notifiable;
|
use Illuminate\Notifications\Notifiable;
|
||||||
|
use Spatie\Permission\Traits\HasRoles;
|
||||||
|
|
||||||
class User extends Authenticatable
|
class User extends Authenticatable
|
||||||
{
|
{
|
||||||
/** @use HasFactory<\Database\Factories\UserFactory> */
|
/** @use HasFactory<\Database\Factories\UserFactory> */
|
||||||
use HasFactory, Notifiable;
|
use HasFactory, Notifiable, HasRoles;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The attributes that are mass assignable.
|
* The attributes that are mass assignable.
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
"inertiajs/inertia-laravel": "^2.0",
|
"inertiajs/inertia-laravel": "^2.0",
|
||||||
"laravel/framework": "^12.0",
|
"laravel/framework": "^12.0",
|
||||||
"laravel/tinker": "^2.10.1",
|
"laravel/tinker": "^2.10.1",
|
||||||
|
"spatie/laravel-permission": "^6.24",
|
||||||
"tightenco/ziggy": "^2.6"
|
"tightenco/ziggy": "^2.6"
|
||||||
},
|
},
|
||||||
"require-dev": {
|
"require-dev": {
|
||||||
|
|||||||
85
composer.lock
generated
85
composer.lock
generated
@@ -4,7 +4,7 @@
|
|||||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||||
"This file is @generated automatically"
|
"This file is @generated automatically"
|
||||||
],
|
],
|
||||||
"content-hash": "56c0c203f0c7715d0a0f4d3d36b1932c",
|
"content-hash": "edfbf8e1cc43c925ea8b04fc1f93da65",
|
||||||
"packages": [
|
"packages": [
|
||||||
{
|
{
|
||||||
"name": "brick/math",
|
"name": "brick/math",
|
||||||
@@ -3360,6 +3360,89 @@
|
|||||||
},
|
},
|
||||||
"time": "2025-12-14T04:43:48+00:00"
|
"time": "2025-12-14T04:43:48+00:00"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "spatie/laravel-permission",
|
||||||
|
"version": "6.24.0",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/spatie/laravel-permission.git",
|
||||||
|
"reference": "76adb1fc8d07c16a0721c35c4cc330b7a12598d7"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/spatie/laravel-permission/zipball/76adb1fc8d07c16a0721c35c4cc330b7a12598d7",
|
||||||
|
"reference": "76adb1fc8d07c16a0721c35c4cc330b7a12598d7",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"illuminate/auth": "^8.12|^9.0|^10.0|^11.0|^12.0",
|
||||||
|
"illuminate/container": "^8.12|^9.0|^10.0|^11.0|^12.0",
|
||||||
|
"illuminate/contracts": "^8.12|^9.0|^10.0|^11.0|^12.0",
|
||||||
|
"illuminate/database": "^8.12|^9.0|^10.0|^11.0|^12.0",
|
||||||
|
"php": "^8.0"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"laravel/passport": "^11.0|^12.0",
|
||||||
|
"laravel/pint": "^1.0",
|
||||||
|
"orchestra/testbench": "^6.23|^7.0|^8.0|^9.0|^10.0",
|
||||||
|
"phpunit/phpunit": "^9.4|^10.1|^11.5"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"extra": {
|
||||||
|
"laravel": {
|
||||||
|
"providers": [
|
||||||
|
"Spatie\\Permission\\PermissionServiceProvider"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"branch-alias": {
|
||||||
|
"dev-main": "6.x-dev",
|
||||||
|
"dev-master": "6.x-dev"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"autoload": {
|
||||||
|
"files": [
|
||||||
|
"src/helpers.php"
|
||||||
|
],
|
||||||
|
"psr-4": {
|
||||||
|
"Spatie\\Permission\\": "src"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Freek Van der Herten",
|
||||||
|
"email": "freek@spatie.be",
|
||||||
|
"homepage": "https://spatie.be",
|
||||||
|
"role": "Developer"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Permission handling for Laravel 8.0 and up",
|
||||||
|
"homepage": "https://github.com/spatie/laravel-permission",
|
||||||
|
"keywords": [
|
||||||
|
"acl",
|
||||||
|
"laravel",
|
||||||
|
"permission",
|
||||||
|
"permissions",
|
||||||
|
"rbac",
|
||||||
|
"roles",
|
||||||
|
"security",
|
||||||
|
"spatie"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"issues": "https://github.com/spatie/laravel-permission/issues",
|
||||||
|
"source": "https://github.com/spatie/laravel-permission/tree/6.24.0"
|
||||||
|
},
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"url": "https://github.com/spatie",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"time": "2025-12-13T21:45:21+00:00"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "symfony/clock",
|
"name": "symfony/clock",
|
||||||
"version": "v8.0.0",
|
"version": "v8.0.0",
|
||||||
|
|||||||
202
config/permission.php
Normal file
202
config/permission.php
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
'models' => [
|
||||||
|
|
||||||
|
/*
|
||||||
|
* When using the "HasPermissions" trait from this package, we need to know which
|
||||||
|
* Eloquent model should be used to retrieve your permissions. Of course, it
|
||||||
|
* is often just the "Permission" model but you may use whatever you like.
|
||||||
|
*
|
||||||
|
* The model you want to use as a Permission model needs to implement the
|
||||||
|
* `Spatie\Permission\Contracts\Permission` contract.
|
||||||
|
*/
|
||||||
|
|
||||||
|
'permission' => Spatie\Permission\Models\Permission::class,
|
||||||
|
|
||||||
|
/*
|
||||||
|
* When using the "HasRoles" trait from this package, we need to know which
|
||||||
|
* Eloquent model should be used to retrieve your roles. Of course, it
|
||||||
|
* is often just the "Role" model but you may use whatever you like.
|
||||||
|
*
|
||||||
|
* The model you want to use as a Role model needs to implement the
|
||||||
|
* `Spatie\Permission\Contracts\Role` contract.
|
||||||
|
*/
|
||||||
|
|
||||||
|
'role' => Spatie\Permission\Models\Role::class,
|
||||||
|
|
||||||
|
],
|
||||||
|
|
||||||
|
'table_names' => [
|
||||||
|
|
||||||
|
/*
|
||||||
|
* When using the "HasRoles" trait from this package, we need to know which
|
||||||
|
* table should be used to retrieve your roles. We have chosen a basic
|
||||||
|
* default value but you may easily change it to any table you like.
|
||||||
|
*/
|
||||||
|
|
||||||
|
'roles' => 'roles',
|
||||||
|
|
||||||
|
/*
|
||||||
|
* When using the "HasPermissions" trait from this package, we need to know which
|
||||||
|
* table should be used to retrieve your permissions. We have chosen a basic
|
||||||
|
* default value but you may easily change it to any table you like.
|
||||||
|
*/
|
||||||
|
|
||||||
|
'permissions' => 'permissions',
|
||||||
|
|
||||||
|
/*
|
||||||
|
* When using the "HasPermissions" trait from this package, we need to know which
|
||||||
|
* table should be used to retrieve your models permissions. We have chosen a
|
||||||
|
* basic default value but you may easily change it to any table you like.
|
||||||
|
*/
|
||||||
|
|
||||||
|
'model_has_permissions' => 'model_has_permissions',
|
||||||
|
|
||||||
|
/*
|
||||||
|
* When using the "HasRoles" trait from this package, we need to know which
|
||||||
|
* table should be used to retrieve your models roles. We have chosen a
|
||||||
|
* basic default value but you may easily change it to any table you like.
|
||||||
|
*/
|
||||||
|
|
||||||
|
'model_has_roles' => 'model_has_roles',
|
||||||
|
|
||||||
|
/*
|
||||||
|
* When using the "HasRoles" trait from this package, we need to know which
|
||||||
|
* table should be used to retrieve your roles permissions. We have chosen a
|
||||||
|
* basic default value but you may easily change it to any table you like.
|
||||||
|
*/
|
||||||
|
|
||||||
|
'role_has_permissions' => 'role_has_permissions',
|
||||||
|
],
|
||||||
|
|
||||||
|
'column_names' => [
|
||||||
|
/*
|
||||||
|
* Change this if you want to name the related pivots other than defaults
|
||||||
|
*/
|
||||||
|
'role_pivot_key' => null, // default 'role_id',
|
||||||
|
'permission_pivot_key' => null, // default 'permission_id',
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Change this if you want to name the related model primary key other than
|
||||||
|
* `model_id`.
|
||||||
|
*
|
||||||
|
* For example, this would be nice if your primary keys are all UUIDs. In
|
||||||
|
* that case, name this `model_uuid`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
'model_morph_key' => 'model_id',
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Change this if you want to use the teams feature and your related model's
|
||||||
|
* foreign key is other than `team_id`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
'team_foreign_key' => 'team_id',
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
* When set to true, the method for checking permissions will be registered on the gate.
|
||||||
|
* Set this to false if you want to implement custom logic for checking permissions.
|
||||||
|
*/
|
||||||
|
|
||||||
|
'register_permission_check_method' => true,
|
||||||
|
|
||||||
|
/*
|
||||||
|
* When set to true, Laravel\Octane\Events\OperationTerminated event listener will be registered
|
||||||
|
* this will refresh permissions on every TickTerminated, TaskTerminated and RequestTerminated
|
||||||
|
* NOTE: This should not be needed in most cases, but an Octane/Vapor combination benefited from it.
|
||||||
|
*/
|
||||||
|
'register_octane_reset_listener' => false,
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Events will fire when a role or permission is assigned/unassigned:
|
||||||
|
* \Spatie\Permission\Events\RoleAttached
|
||||||
|
* \Spatie\Permission\Events\RoleDetached
|
||||||
|
* \Spatie\Permission\Events\PermissionAttached
|
||||||
|
* \Spatie\Permission\Events\PermissionDetached
|
||||||
|
*
|
||||||
|
* To enable, set to true, and then create listeners to watch these events.
|
||||||
|
*/
|
||||||
|
'events_enabled' => false,
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Teams Feature.
|
||||||
|
* When set to true the package implements teams using the 'team_foreign_key'.
|
||||||
|
* If you want the migrations to register the 'team_foreign_key', you must
|
||||||
|
* set this to true before doing the migration.
|
||||||
|
* If you already did the migration then you must make a new migration to also
|
||||||
|
* add 'team_foreign_key' to 'roles', 'model_has_roles', and 'model_has_permissions'
|
||||||
|
* (view the latest version of this package's migration file)
|
||||||
|
*/
|
||||||
|
|
||||||
|
'teams' => false,
|
||||||
|
|
||||||
|
/*
|
||||||
|
* The class to use to resolve the permissions team id
|
||||||
|
*/
|
||||||
|
'team_resolver' => \Spatie\Permission\DefaultTeamResolver::class,
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Passport Client Credentials Grant
|
||||||
|
* When set to true the package will use Passports Client to check permissions
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use_passport_client_credentials' => false,
|
||||||
|
|
||||||
|
/*
|
||||||
|
* When set to true, the required permission names are added to exception messages.
|
||||||
|
* This could be considered an information leak in some contexts, so the default
|
||||||
|
* setting is false here for optimum safety.
|
||||||
|
*/
|
||||||
|
|
||||||
|
'display_permission_in_exception' => false,
|
||||||
|
|
||||||
|
/*
|
||||||
|
* When set to true, the required role names are added to exception messages.
|
||||||
|
* This could be considered an information leak in some contexts, so the default
|
||||||
|
* setting is false here for optimum safety.
|
||||||
|
*/
|
||||||
|
|
||||||
|
'display_role_in_exception' => false,
|
||||||
|
|
||||||
|
/*
|
||||||
|
* By default wildcard permission lookups are disabled.
|
||||||
|
* See documentation to understand supported syntax.
|
||||||
|
*/
|
||||||
|
|
||||||
|
'enable_wildcard_permission' => false,
|
||||||
|
|
||||||
|
/*
|
||||||
|
* The class to use for interpreting wildcard permissions.
|
||||||
|
* If you need to modify delimiters, override the class and specify its name here.
|
||||||
|
*/
|
||||||
|
// 'wildcard_permission' => Spatie\Permission\WildcardPermission::class,
|
||||||
|
|
||||||
|
/* Cache-specific settings */
|
||||||
|
|
||||||
|
'cache' => [
|
||||||
|
|
||||||
|
/*
|
||||||
|
* By default all permissions are cached for 24 hours to speed up performance.
|
||||||
|
* When permissions or roles are updated the cache is flushed automatically.
|
||||||
|
*/
|
||||||
|
|
||||||
|
'expiration_time' => \DateInterval::createFromDateString('24 hours'),
|
||||||
|
|
||||||
|
/*
|
||||||
|
* The cache key used to store all permissions.
|
||||||
|
*/
|
||||||
|
|
||||||
|
'key' => 'spatie.permission.cache',
|
||||||
|
|
||||||
|
/*
|
||||||
|
* You may optionally indicate a specific cache driver to use for permission and
|
||||||
|
* role caching using any of the `store` drivers listed in the cache.php config
|
||||||
|
* file. Using 'default' here means to use the `default` set in cache.php.
|
||||||
|
*/
|
||||||
|
|
||||||
|
'store' => 'default',
|
||||||
|
],
|
||||||
|
];
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
<?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
|
||||||
|
{
|
||||||
|
$teams = config('permission.teams');
|
||||||
|
$tableNames = config('permission.table_names');
|
||||||
|
$columnNames = config('permission.column_names');
|
||||||
|
$pivotRole = $columnNames['role_pivot_key'] ?? 'role_id';
|
||||||
|
$pivotPermission = $columnNames['permission_pivot_key'] ?? 'permission_id';
|
||||||
|
|
||||||
|
throw_if(empty($tableNames), Exception::class, 'Error: config/permission.php not loaded. Run [php artisan config:clear] and try again.');
|
||||||
|
throw_if($teams && empty($columnNames['team_foreign_key'] ?? null), Exception::class, 'Error: team_foreign_key on config/permission.php not loaded. Run [php artisan config:clear] and try again.');
|
||||||
|
|
||||||
|
Schema::create($tableNames['permissions'], static function (Blueprint $table) {
|
||||||
|
// $table->engine('InnoDB');
|
||||||
|
$table->bigIncrements('id'); // permission id
|
||||||
|
$table->string('name'); // For MyISAM use string('name', 225); // (or 166 for InnoDB with Redundant/Compact row format)
|
||||||
|
$table->string('guard_name'); // For MyISAM use string('guard_name', 25);
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
$table->unique(['name', 'guard_name']);
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::create($tableNames['roles'], static function (Blueprint $table) use ($teams, $columnNames) {
|
||||||
|
// $table->engine('InnoDB');
|
||||||
|
$table->bigIncrements('id'); // role id
|
||||||
|
if ($teams || config('permission.testing')) { // permission.testing is a fix for sqlite testing
|
||||||
|
$table->unsignedBigInteger($columnNames['team_foreign_key'])->nullable();
|
||||||
|
$table->index($columnNames['team_foreign_key'], 'roles_team_foreign_key_index');
|
||||||
|
}
|
||||||
|
$table->string('name'); // For MyISAM use string('name', 225); // (or 166 for InnoDB with Redundant/Compact row format)
|
||||||
|
$table->string('guard_name'); // For MyISAM use string('guard_name', 25);
|
||||||
|
$table->timestamps();
|
||||||
|
if ($teams || config('permission.testing')) {
|
||||||
|
$table->unique([$columnNames['team_foreign_key'], 'name', 'guard_name']);
|
||||||
|
} else {
|
||||||
|
$table->unique(['name', 'guard_name']);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::create($tableNames['model_has_permissions'], static function (Blueprint $table) use ($tableNames, $columnNames, $pivotPermission, $teams) {
|
||||||
|
$table->unsignedBigInteger($pivotPermission);
|
||||||
|
|
||||||
|
$table->string('model_type');
|
||||||
|
$table->unsignedBigInteger($columnNames['model_morph_key']);
|
||||||
|
$table->index([$columnNames['model_morph_key'], 'model_type'], 'model_has_permissions_model_id_model_type_index');
|
||||||
|
|
||||||
|
$table->foreign($pivotPermission)
|
||||||
|
->references('id') // permission id
|
||||||
|
->on($tableNames['permissions'])
|
||||||
|
->onDelete('cascade');
|
||||||
|
if ($teams) {
|
||||||
|
$table->unsignedBigInteger($columnNames['team_foreign_key']);
|
||||||
|
$table->index($columnNames['team_foreign_key'], 'model_has_permissions_team_foreign_key_index');
|
||||||
|
|
||||||
|
$table->primary([$columnNames['team_foreign_key'], $pivotPermission, $columnNames['model_morph_key'], 'model_type'],
|
||||||
|
'model_has_permissions_permission_model_type_primary');
|
||||||
|
} else {
|
||||||
|
$table->primary([$pivotPermission, $columnNames['model_morph_key'], 'model_type'],
|
||||||
|
'model_has_permissions_permission_model_type_primary');
|
||||||
|
}
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::create($tableNames['model_has_roles'], static function (Blueprint $table) use ($tableNames, $columnNames, $pivotRole, $teams) {
|
||||||
|
$table->unsignedBigInteger($pivotRole);
|
||||||
|
|
||||||
|
$table->string('model_type');
|
||||||
|
$table->unsignedBigInteger($columnNames['model_morph_key']);
|
||||||
|
$table->index([$columnNames['model_morph_key'], 'model_type'], 'model_has_roles_model_id_model_type_index');
|
||||||
|
|
||||||
|
$table->foreign($pivotRole)
|
||||||
|
->references('id') // role id
|
||||||
|
->on($tableNames['roles'])
|
||||||
|
->onDelete('cascade');
|
||||||
|
if ($teams) {
|
||||||
|
$table->unsignedBigInteger($columnNames['team_foreign_key']);
|
||||||
|
$table->index($columnNames['team_foreign_key'], 'model_has_roles_team_foreign_key_index');
|
||||||
|
|
||||||
|
$table->primary([$columnNames['team_foreign_key'], $pivotRole, $columnNames['model_morph_key'], 'model_type'],
|
||||||
|
'model_has_roles_role_model_type_primary');
|
||||||
|
} else {
|
||||||
|
$table->primary([$pivotRole, $columnNames['model_morph_key'], 'model_type'],
|
||||||
|
'model_has_roles_role_model_type_primary');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::create($tableNames['role_has_permissions'], static function (Blueprint $table) use ($tableNames, $pivotRole, $pivotPermission) {
|
||||||
|
$table->unsignedBigInteger($pivotPermission);
|
||||||
|
$table->unsignedBigInteger($pivotRole);
|
||||||
|
|
||||||
|
$table->foreign($pivotPermission)
|
||||||
|
->references('id') // permission id
|
||||||
|
->on($tableNames['permissions'])
|
||||||
|
->onDelete('cascade');
|
||||||
|
|
||||||
|
$table->foreign($pivotRole)
|
||||||
|
->references('id') // role id
|
||||||
|
->on($tableNames['roles'])
|
||||||
|
->onDelete('cascade');
|
||||||
|
|
||||||
|
$table->primary([$pivotPermission, $pivotRole], 'role_has_permissions_permission_id_role_id_primary');
|
||||||
|
});
|
||||||
|
|
||||||
|
app('cache')
|
||||||
|
->store(config('permission.cache.store') != 'default' ? config('permission.cache.store') : null)
|
||||||
|
->forget(config('permission.cache.key'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
$tableNames = config('permission.table_names');
|
||||||
|
|
||||||
|
throw_if(empty($tableNames), Exception::class, 'Error: config/permission.php not found and defaults could not be merged. Please publish the package configuration before proceeding, or drop the tables manually.');
|
||||||
|
|
||||||
|
Schema::drop($tableNames['role_has_permissions']);
|
||||||
|
Schema::drop($tableNames['model_has_roles']);
|
||||||
|
Schema::drop($tableNames['model_has_permissions']);
|
||||||
|
Schema::drop($tableNames['roles']);
|
||||||
|
Schema::drop($tableNames['permissions']);
|
||||||
|
}
|
||||||
|
};
|
||||||
122
database/seeders/PermissionSeeder.php
Normal file
122
database/seeders/PermissionSeeder.php
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Database\Seeders;
|
||||||
|
|
||||||
|
use Illuminate\Database\Seeder;
|
||||||
|
use Spatie\Permission\Models\Role;
|
||||||
|
use Spatie\Permission\Models\Permission;
|
||||||
|
use App\Models\User;
|
||||||
|
|
||||||
|
class PermissionSeeder extends Seeder
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the database seeds.
|
||||||
|
*/
|
||||||
|
public function run(): void
|
||||||
|
{
|
||||||
|
// 重置快取
|
||||||
|
app()[\Spatie\Permission\PermissionRegistrar::class]->forgetCachedPermissions();
|
||||||
|
|
||||||
|
// 建立權限
|
||||||
|
$permissions = [
|
||||||
|
// 產品管理
|
||||||
|
'products.view',
|
||||||
|
'products.create',
|
||||||
|
'products.edit',
|
||||||
|
'products.delete',
|
||||||
|
|
||||||
|
// 採購單管理
|
||||||
|
'purchase_orders.view',
|
||||||
|
'purchase_orders.create',
|
||||||
|
'purchase_orders.edit',
|
||||||
|
'purchase_orders.delete',
|
||||||
|
'purchase_orders.publish',
|
||||||
|
|
||||||
|
// 庫存管理
|
||||||
|
'inventory.view',
|
||||||
|
'inventory.adjust',
|
||||||
|
'inventory.transfer',
|
||||||
|
|
||||||
|
// 供應商管理
|
||||||
|
'vendors.view',
|
||||||
|
'vendors.create',
|
||||||
|
'vendors.edit',
|
||||||
|
'vendors.delete',
|
||||||
|
|
||||||
|
// 倉庫管理
|
||||||
|
'warehouses.view',
|
||||||
|
'warehouses.create',
|
||||||
|
'warehouses.edit',
|
||||||
|
'warehouses.delete',
|
||||||
|
|
||||||
|
// 使用者管理
|
||||||
|
'users.view',
|
||||||
|
'users.create',
|
||||||
|
'users.edit',
|
||||||
|
'users.delete',
|
||||||
|
|
||||||
|
// 角色權限管理
|
||||||
|
'roles.view',
|
||||||
|
'roles.create',
|
||||||
|
'roles.edit',
|
||||||
|
'roles.delete',
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($permissions as $permission) {
|
||||||
|
Permission::create(['name' => $permission]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 建立角色
|
||||||
|
$superAdmin = Role::create(['name' => 'super-admin']);
|
||||||
|
$admin = Role::create(['name' => 'admin']);
|
||||||
|
$warehouseManager = Role::create(['name' => 'warehouse-manager']);
|
||||||
|
$purchaser = Role::create(['name' => 'purchaser']);
|
||||||
|
$viewer = Role::create(['name' => 'viewer']);
|
||||||
|
|
||||||
|
// 給角色分配權限
|
||||||
|
// super-admin 擁有所有權限
|
||||||
|
$superAdmin->givePermissionTo(Permission::all());
|
||||||
|
|
||||||
|
// admin 擁有大部分權限(除了角色管理)
|
||||||
|
$admin->givePermissionTo([
|
||||||
|
'products.view', 'products.create', 'products.edit', 'products.delete',
|
||||||
|
'purchase_orders.view', 'purchase_orders.create', 'purchase_orders.edit',
|
||||||
|
'purchase_orders.delete', 'purchase_orders.publish',
|
||||||
|
'inventory.view', 'inventory.adjust', 'inventory.transfer',
|
||||||
|
'vendors.view', 'vendors.create', 'vendors.edit', 'vendors.delete',
|
||||||
|
'warehouses.view', 'warehouses.create', 'warehouses.edit', 'warehouses.delete',
|
||||||
|
'users.view', 'users.create', 'users.edit',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// warehouse-manager 管理庫存與倉庫
|
||||||
|
$warehouseManager->givePermissionTo([
|
||||||
|
'products.view',
|
||||||
|
'inventory.view', 'inventory.adjust', 'inventory.transfer',
|
||||||
|
'warehouses.view', 'warehouses.create', 'warehouses.edit',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// purchaser 管理採購與供應商
|
||||||
|
$purchaser->givePermissionTo([
|
||||||
|
'products.view',
|
||||||
|
'purchase_orders.view', 'purchase_orders.create', 'purchase_orders.edit',
|
||||||
|
'vendors.view', 'vendors.create', 'vendors.edit',
|
||||||
|
'inventory.view',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// viewer 僅能查看
|
||||||
|
$viewer->givePermissionTo([
|
||||||
|
'products.view',
|
||||||
|
'purchase_orders.view',
|
||||||
|
'inventory.view',
|
||||||
|
'vendors.view',
|
||||||
|
'warehouses.view',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 將現有使用者設為 super-admin(如果存在的話)
|
||||||
|
$firstUser = User::first();
|
||||||
|
if ($firstUser) {
|
||||||
|
$firstUser->assignRole('super-admin');
|
||||||
|
$this->command->info("已將使用者 {$firstUser->name} 設為 super-admin");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
11
package-lock.json
generated
11
package-lock.json
generated
@@ -22,6 +22,7 @@
|
|||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
|
"date-fns": "^4.1.0",
|
||||||
"jsbarcode": "^3.12.1",
|
"jsbarcode": "^3.12.1",
|
||||||
"lodash": "^4.17.21",
|
"lodash": "^4.17.21",
|
||||||
"lucide-react": "^0.562.0",
|
"lucide-react": "^0.562.0",
|
||||||
@@ -2844,6 +2845,16 @@
|
|||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/date-fns": {
|
||||||
|
"version": "4.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
|
||||||
|
"integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/kossnocorp"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/debug": {
|
"node_modules/debug": {
|
||||||
"version": "4.4.3",
|
"version": "4.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||||
|
|||||||
@@ -36,6 +36,7 @@
|
|||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
|
"date-fns": "^4.1.0",
|
||||||
"jsbarcode": "^3.12.1",
|
"jsbarcode": "^3.12.1",
|
||||||
"lodash": "^4.17.21",
|
"lodash": "^4.17.21",
|
||||||
"lucide-react": "^0.562.0",
|
"lucide-react": "^0.562.0",
|
||||||
|
|||||||
84
resources/js/Components/Permission/Can.tsx
Normal file
84
resources/js/Components/Permission/Can.tsx
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
import { usePermission } from '@/hooks/usePermission';
|
||||||
|
import { ReactNode } from 'react';
|
||||||
|
|
||||||
|
interface CanProps {
|
||||||
|
permission: string | string[];
|
||||||
|
children: ReactNode;
|
||||||
|
fallback?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 權限判斷元件 - 類似 Blade 的 @can 指令
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* <Can permission="products.create">
|
||||||
|
* <button>新增商品</button>
|
||||||
|
* </Can>
|
||||||
|
*
|
||||||
|
* <Can permission={['products.edit', 'products.delete']}>
|
||||||
|
* <div>管理操作</div>
|
||||||
|
* </Can>
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function Can({ permission, children, fallback = null }: CanProps) {
|
||||||
|
const { can, canAny } = usePermission();
|
||||||
|
|
||||||
|
const hasPermission = Array.isArray(permission)
|
||||||
|
? canAny(permission)
|
||||||
|
: can(permission);
|
||||||
|
|
||||||
|
return hasPermission ? <>{children}</> : <>{fallback}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HasRoleProps {
|
||||||
|
role: string | string[];
|
||||||
|
children: ReactNode;
|
||||||
|
fallback?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 角色判斷元件 - 類似 Blade 的 @role 指令
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* <HasRole role="admin">
|
||||||
|
* <Link href="/admin">管理後台</Link>
|
||||||
|
* </HasRole>
|
||||||
|
*
|
||||||
|
* <HasRole role={['admin', 'manager']}>
|
||||||
|
* <button>管理選項</button>
|
||||||
|
* </HasRole>
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function HasRole({ role, children, fallback = null }: HasRoleProps) {
|
||||||
|
const { hasRole, hasAnyRole } = usePermission();
|
||||||
|
|
||||||
|
const hasRequiredRole = Array.isArray(role)
|
||||||
|
? hasAnyRole(role)
|
||||||
|
: hasRole(role);
|
||||||
|
|
||||||
|
return hasRequiredRole ? <>{children}</> : <>{fallback}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CanAllProps {
|
||||||
|
permissions: string[];
|
||||||
|
children: ReactNode;
|
||||||
|
fallback?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 檢查是否擁有所有權限
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* <CanAll permissions={['products.edit', 'products.delete']}>
|
||||||
|
* <button>完整管理</button>
|
||||||
|
* </CanAll>
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function CanAll({ permissions, children, fallback = null }: CanAllProps) {
|
||||||
|
const { canAll } = usePermission();
|
||||||
|
|
||||||
|
return canAll(permissions) ? <>{children}</> : <>{fallback}</>;
|
||||||
|
}
|
||||||
@@ -20,7 +20,7 @@ export function PurchaseOrderActions({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex justify-end gap-2">
|
<div className="flex justify-center gap-2">
|
||||||
<Link href={`/purchase-orders/${order.id}`}>
|
<Link href={`/purchase-orders/${order.id}`}>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
|||||||
@@ -174,7 +174,7 @@ export default function PurchaseOrderTable({
|
|||||||
<SortIcon field="status" />
|
<SortIcon field="status" />
|
||||||
</button>
|
</button>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead className="text-right font-semibold">操作</TableHead>
|
<TableHead className="text-center font-semibold">操作</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
@@ -214,7 +214,7 @@ export default function PurchaseOrderTable({
|
|||||||
<TableCell>
|
<TableCell>
|
||||||
<PurchaseOrderStatusBadge status={order.status} />
|
<PurchaseOrderStatusBadge status={order.status} />
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell className="text-center">
|
||||||
<PurchaseOrderActions
|
<PurchaseOrderActions
|
||||||
order={order}
|
order={order}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -13,7 +13,10 @@ import {
|
|||||||
FileText,
|
FileText,
|
||||||
LogOut,
|
LogOut,
|
||||||
User,
|
User,
|
||||||
ChevronDown
|
ChevronDown,
|
||||||
|
Settings,
|
||||||
|
Shield,
|
||||||
|
Users
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { toast, Toaster } from "sonner";
|
import { toast, Toaster } from "sonner";
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
@@ -101,6 +104,25 @@ export default function AuthenticatedLayout({
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "system-management",
|
||||||
|
label: "系統管理",
|
||||||
|
icon: <Settings className="h-5 w-5" />,
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
id: "user-management",
|
||||||
|
label: "使用者管理",
|
||||||
|
icon: <Users className="h-4 w-4" />,
|
||||||
|
route: "/admin/users",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "role-management",
|
||||||
|
label: "角色與權限",
|
||||||
|
icon: <Shield className="h-4 w-4" />,
|
||||||
|
route: "/admin/roles",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
// 初始化狀態:優先讀取 localStorage
|
// 初始化狀態:優先讀取 localStorage
|
||||||
|
|||||||
197
resources/js/Pages/Admin/Role/Create.tsx
Normal file
197
resources/js/Pages/Admin/Role/Create.tsx
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout';
|
||||||
|
import { Head, Link, useForm } from '@inertiajs/react';
|
||||||
|
import { Shield, ArrowLeft, Check } from 'lucide-react';
|
||||||
|
import { Button } from '@/Components/ui/button';
|
||||||
|
import { Input } from '@/Components/ui/input';
|
||||||
|
import { Label } from '@/Components/ui/label';
|
||||||
|
import { Checkbox } from '@/Components/ui/checkbox';
|
||||||
|
import { FormEvent } from 'react';
|
||||||
|
|
||||||
|
interface Permission {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GroupedPermission {
|
||||||
|
key: string;
|
||||||
|
name: string;
|
||||||
|
permissions: Permission[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
groupedPermissions: GroupedPermission[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RoleCreate({ groupedPermissions }: Props) {
|
||||||
|
const { data, setData, post, processing, errors } = useForm({
|
||||||
|
name: '',
|
||||||
|
permissions: [] as string[],
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSubmit = (e: FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
post(route('roles.store'));
|
||||||
|
};
|
||||||
|
|
||||||
|
const togglePermission = (name: string) => {
|
||||||
|
if (data.permissions.includes(name)) {
|
||||||
|
setData('permissions', data.permissions.filter(p => p !== name));
|
||||||
|
} else {
|
||||||
|
setData('permissions', [...data.permissions, name]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleGroup = (groupPermissions: Permission[]) => {
|
||||||
|
const groupNames = groupPermissions.map(p => p.name);
|
||||||
|
const allSelected = groupNames.every(name => data.permissions.includes(name));
|
||||||
|
|
||||||
|
if (allSelected) {
|
||||||
|
// Unselect all
|
||||||
|
setData('permissions', data.permissions.filter(p => !groupNames.includes(p)));
|
||||||
|
} else {
|
||||||
|
// Select all
|
||||||
|
const newPermissions = [...data.permissions];
|
||||||
|
groupNames.forEach(name => {
|
||||||
|
if (!newPermissions.includes(name)) newPermissions.push(name);
|
||||||
|
});
|
||||||
|
setData('permissions', newPermissions);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 翻譯權限後綴
|
||||||
|
const translateAction = (permissionName: string) => {
|
||||||
|
const parts = permissionName.split('.');
|
||||||
|
if (parts.length < 2) return permissionName;
|
||||||
|
const action = parts[1];
|
||||||
|
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
'view': '檢視',
|
||||||
|
'create': '新增',
|
||||||
|
'edit': '編輯',
|
||||||
|
'delete': '刪除',
|
||||||
|
'publish': '發布',
|
||||||
|
'adjust': '調整',
|
||||||
|
'transfer': '調撥',
|
||||||
|
};
|
||||||
|
|
||||||
|
return map[action] || action;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthenticatedLayout
|
||||||
|
breadcrumbs={[
|
||||||
|
{ label: '系統管理', href: '#' },
|
||||||
|
{ label: '角色與權限', href: route('roles.index') },
|
||||||
|
{ label: '建立角色', href: route('roles.create'), isPage: true },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Head title="建立角色" />
|
||||||
|
|
||||||
|
<div className="p-8 max-w-7xl mx-auto">
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-8">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-grey-0 flex items-center gap-2">
|
||||||
|
<Shield className="h-6 w-6 text-[#01ab83]" />
|
||||||
|
建立新角色
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-500 mt-1">
|
||||||
|
設定角色名稱並分配對應的操作權限
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Link href={route('roles.index')}>
|
||||||
|
<Button variant="outline" type="button">
|
||||||
|
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
className="bg-[#01ab83] hover:bg-[#019a76]"
|
||||||
|
disabled={processing}
|
||||||
|
>
|
||||||
|
<Check className="h-4 w-4 mr-2" />
|
||||||
|
儲存角色
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Role Name */}
|
||||||
|
<div className="bg-white p-6 rounded-xl border border-gray-200 shadow-sm">
|
||||||
|
<div className="max-w-md space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="name">角色名稱 (英文代號)</Label>
|
||||||
|
<Input
|
||||||
|
id="name"
|
||||||
|
placeholder="e.g. sales-manager"
|
||||||
|
value={data.name}
|
||||||
|
onChange={e => setData('name', e.target.value)}
|
||||||
|
className="font-mono"
|
||||||
|
/>
|
||||||
|
{errors.name && (
|
||||||
|
<p className="text-sm text-red-500">{errors.name}</p>
|
||||||
|
)}
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
請使用英文字母與連字號,例如: <code>warehouse-staff</code>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Permissions Matrix */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h2 className="text-lg font-bold text-grey-0">權限設定</h2>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
|
{groupedPermissions.map((group) => {
|
||||||
|
const allGroupSelected = group.permissions.every(p => data.permissions.includes(p.name));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={group.key} className="bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden flex flex-col">
|
||||||
|
<div className="bg-gray-50 px-4 py-3 border-b border-gray-200 flex items-center justify-between">
|
||||||
|
<span className="font-medium text-gray-700">{group.name}</span>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => toggleGroup(group.permissions)}
|
||||||
|
className="text-xs h-7 text-[#01ab83] hover:text-[#01ab83] hover:bg-[#01ab83]/10"
|
||||||
|
>
|
||||||
|
{allGroupSelected ? '取消全選' : '全選'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 flex-1">
|
||||||
|
<div className="space-y-3">
|
||||||
|
{group.permissions.map((permission) => (
|
||||||
|
<div key={permission.id} className="flex items-start space-x-3">
|
||||||
|
<Checkbox
|
||||||
|
id={permission.name}
|
||||||
|
checked={data.permissions.includes(permission.name)}
|
||||||
|
onCheckedChange={() => togglePermission(permission.name)}
|
||||||
|
/>
|
||||||
|
<div className="grid gap-1.5 leading-none">
|
||||||
|
<label
|
||||||
|
htmlFor={permission.name}
|
||||||
|
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 cursor-pointer"
|
||||||
|
>
|
||||||
|
{translateAction(permission.name)}
|
||||||
|
</label>
|
||||||
|
<p className="text-[10px] text-gray-400 font-mono">
|
||||||
|
{permission.name}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</AuthenticatedLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
210
resources/js/Pages/Admin/Role/Edit.tsx
Normal file
210
resources/js/Pages/Admin/Role/Edit.tsx
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout';
|
||||||
|
import { Head, Link, useForm } from '@inertiajs/react';
|
||||||
|
import { Shield, ArrowLeft, Check, AlertCircle } from 'lucide-react';
|
||||||
|
import { Button } from '@/Components/ui/button';
|
||||||
|
import { Input } from '@/Components/ui/input';
|
||||||
|
import { Label } from '@/Components/ui/label';
|
||||||
|
import { Checkbox } from '@/Components/ui/checkbox';
|
||||||
|
import { FormEvent } from 'react';
|
||||||
|
|
||||||
|
interface Permission {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GroupedPermission {
|
||||||
|
key: string;
|
||||||
|
name: string;
|
||||||
|
permissions: Permission[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Role {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
role: Role;
|
||||||
|
groupedPermissions: GroupedPermission[];
|
||||||
|
currentPermissions: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RoleEdit({ role, groupedPermissions, currentPermissions }: Props) {
|
||||||
|
const { data, setData, put, processing, errors } = useForm({
|
||||||
|
name: role.name,
|
||||||
|
permissions: currentPermissions,
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSubmit = (e: FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
put(route('roles.update', role.id));
|
||||||
|
};
|
||||||
|
|
||||||
|
const togglePermission = (name: string) => {
|
||||||
|
if (data.permissions.includes(name)) {
|
||||||
|
setData('permissions', data.permissions.filter(p => p !== name));
|
||||||
|
} else {
|
||||||
|
setData('permissions', [...data.permissions, name]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleGroup = (groupPermissions: Permission[]) => {
|
||||||
|
const groupNames = groupPermissions.map(p => p.name);
|
||||||
|
const allSelected = groupNames.every(name => data.permissions.includes(name));
|
||||||
|
|
||||||
|
if (allSelected) {
|
||||||
|
// Unselect all
|
||||||
|
setData('permissions', data.permissions.filter(p => !groupNames.includes(p)));
|
||||||
|
} else {
|
||||||
|
// Select all
|
||||||
|
const newPermissions = [...data.permissions];
|
||||||
|
groupNames.forEach(name => {
|
||||||
|
if (!newPermissions.includes(name)) newPermissions.push(name);
|
||||||
|
});
|
||||||
|
setData('permissions', newPermissions);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const translateAction = (permissionName: string) => {
|
||||||
|
const parts = permissionName.split('.');
|
||||||
|
if (parts.length < 2) return permissionName;
|
||||||
|
const action = parts[1];
|
||||||
|
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
'view': '檢視',
|
||||||
|
'create': '新增',
|
||||||
|
'edit': '編輯',
|
||||||
|
'delete': '刪除',
|
||||||
|
'publish': '發布',
|
||||||
|
'adjust': '調整',
|
||||||
|
'transfer': '調撥',
|
||||||
|
};
|
||||||
|
|
||||||
|
return map[action] || action;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthenticatedLayout
|
||||||
|
breadcrumbs={[
|
||||||
|
{ label: '系統管理', href: '#' },
|
||||||
|
{ label: '角色與權限', href: route('roles.index') },
|
||||||
|
{ label: '編輯角色', href: route('roles.edit', role.id), isPage: true },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Head title={`編輯角色 - ${role.name}`} />
|
||||||
|
|
||||||
|
<div className="p-8 max-w-7xl mx-auto">
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-8">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-grey-0 flex items-center gap-2">
|
||||||
|
<Shield className="h-6 w-6 text-[#01ab83]" />
|
||||||
|
編輯角色
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-500 mt-1">
|
||||||
|
修改角色資料與權限設定
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Link href={route('roles.index')}>
|
||||||
|
<Button variant="outline" type="button">
|
||||||
|
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
className="bg-[#01ab83] hover:bg-[#019a76]"
|
||||||
|
disabled={processing}
|
||||||
|
>
|
||||||
|
<Check className="h-4 w-4 mr-2" />
|
||||||
|
儲存變更
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Role Name */}
|
||||||
|
<div className="bg-white p-6 rounded-xl border border-gray-200 shadow-sm">
|
||||||
|
<div className="max-w-md space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="name">角色名稱 (英文代號)</Label>
|
||||||
|
<Input
|
||||||
|
id="name"
|
||||||
|
value={data.name}
|
||||||
|
onChange={e => setData('name', e.target.value)}
|
||||||
|
className="font-mono bg-gray-50"
|
||||||
|
disabled={role.name === 'super-admin'} // Should be handled by controller redirect, but extra safety
|
||||||
|
/>
|
||||||
|
{errors.name && (
|
||||||
|
<p className="text-sm text-red-500">{errors.name}</p>
|
||||||
|
)}
|
||||||
|
{role.name === 'super-admin' ? (
|
||||||
|
<div className="flex items-center gap-2 text-amber-600 text-sm mt-2">
|
||||||
|
<AlertCircle className="h-4 w-4" />
|
||||||
|
<span>超級管理員角色名稱不可修改</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
請使用英文字母與連字號,例如: <code>warehouse-staff</code>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Permissions Matrix */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h2 className="text-lg font-bold text-grey-0">權限設定</h2>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
|
{groupedPermissions.map((group) => {
|
||||||
|
const allGroupSelected = group.permissions.every(p => data.permissions.includes(p.name));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={group.key} className="bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden flex flex-col">
|
||||||
|
<div className="bg-gray-50 px-4 py-3 border-b border-gray-200 flex items-center justify-between">
|
||||||
|
<span className="font-medium text-gray-700">{group.name}</span>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => toggleGroup(group.permissions)}
|
||||||
|
className="text-xs h-7 text-[#01ab83] hover:text-[#01ab83] hover:bg-[#01ab83]/10"
|
||||||
|
>
|
||||||
|
{allGroupSelected ? '取消全選' : '全選'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 flex-1">
|
||||||
|
<div className="space-y-3">
|
||||||
|
{group.permissions.map((permission) => (
|
||||||
|
<div key={permission.id} className="flex items-start space-x-3">
|
||||||
|
<Checkbox
|
||||||
|
id={permission.name}
|
||||||
|
checked={data.permissions.includes(permission.name)}
|
||||||
|
onCheckedChange={() => togglePermission(permission.name)}
|
||||||
|
/>
|
||||||
|
<div className="grid gap-1.5 leading-none">
|
||||||
|
<label
|
||||||
|
htmlFor={permission.name}
|
||||||
|
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 cursor-pointer"
|
||||||
|
>
|
||||||
|
{translateAction(permission.name)}
|
||||||
|
</label>
|
||||||
|
<p className="text-[10px] text-gray-400 font-mono">
|
||||||
|
{permission.name}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</AuthenticatedLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
150
resources/js/Pages/Admin/Role/Index.tsx
Normal file
150
resources/js/Pages/Admin/Role/Index.tsx
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout';
|
||||||
|
import { Head, Link, router } from '@inertiajs/react';
|
||||||
|
import { Shield, Plus, Pencil, Trash2, Users } from 'lucide-react';
|
||||||
|
import { Button } from '@/Components/ui/button';
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/Components/ui/table";
|
||||||
|
import { format } from 'date-fns';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
interface Role {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
users_count: number;
|
||||||
|
permissions_count: number;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
roles: Role[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RoleIndex({ roles }: Props) {
|
||||||
|
const handleDelete = (id: number, name: string) => {
|
||||||
|
if (confirm(`確定要刪除角色「${name}」嗎?此操作無法復原。`)) {
|
||||||
|
router.delete(route('roles.destroy', id), {
|
||||||
|
onSuccess: () => toast.success('角色已刪除'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const translateRoleName = (name: string) => {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
'super-admin': '超級管理員',
|
||||||
|
'admin': '管理員',
|
||||||
|
'warehouse-manager': '倉庫主管',
|
||||||
|
'purchaser': '採購人員',
|
||||||
|
'viewer': '檢視者',
|
||||||
|
};
|
||||||
|
return map[name] || name;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthenticatedLayout
|
||||||
|
breadcrumbs={[
|
||||||
|
{ label: '系統管理', href: '#' },
|
||||||
|
{ label: '角色與權限', href: route('roles.index'), isPage: true },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Head title="角色管理" />
|
||||||
|
|
||||||
|
<div className="p-8 max-w-7xl mx-auto space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-grey-0 flex items-center gap-2">
|
||||||
|
<Shield className="h-6 w-6 text-[#01ab83]" />
|
||||||
|
角色與權限
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-500 mt-1">
|
||||||
|
設定系統角色與功能存取權限
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Link href={route('roles.create')}>
|
||||||
|
<Button className="bg-[#01ab83] hover:bg-[#019a76]">
|
||||||
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
|
新增角色
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden">
|
||||||
|
<Table>
|
||||||
|
<TableHeader className="bg-gray-50">
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="w-[200px]">角色名稱</TableHead>
|
||||||
|
<TableHead>代號</TableHead>
|
||||||
|
<TableHead className="text-center">權限數量</TableHead>
|
||||||
|
<TableHead className="text-center">使用者人數</TableHead>
|
||||||
|
<TableHead className="text-left">建立時間</TableHead>
|
||||||
|
<TableHead className="text-center">操作</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{roles.map((role) => (
|
||||||
|
<TableRow key={role.id}>
|
||||||
|
<TableCell className="font-medium">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="p-2 bg-gray-100 rounded-lg">
|
||||||
|
<Shield className="h-4 w-4 text-gray-500" />
|
||||||
|
</div>
|
||||||
|
{translateRoleName(role.name)}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-gray-500 font-mono text-xs">
|
||||||
|
{role.name}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-center">
|
||||||
|
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
|
||||||
|
{role.permissions_count} 項權限
|
||||||
|
</span>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-center">
|
||||||
|
<div className="flex items-center justify-center gap-1 text-gray-600">
|
||||||
|
<Users className="h-3 w-3" />
|
||||||
|
{role.users_count}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-left text-gray-500 text-sm">
|
||||||
|
{format(new Date(role.created_at), 'yyyy/MM/dd')}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-center">
|
||||||
|
{role.name !== 'super-admin' && (
|
||||||
|
<div className="flex items-center justify-center gap-2">
|
||||||
|
<Link href={route('roles.edit', role.id)}>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="button-outlined-primary h-8 w-8 p-0"
|
||||||
|
title="編輯"
|
||||||
|
>
|
||||||
|
<Pencil className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="button-outlined-error h-8 w-8 p-0"
|
||||||
|
title="刪除"
|
||||||
|
disabled={role.users_count > 0}
|
||||||
|
onClick={() => handleDelete(role.id, translateRoleName(role.name))}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AuthenticatedLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
202
resources/js/Pages/Admin/User/Create.tsx
Normal file
202
resources/js/Pages/Admin/User/Create.tsx
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout';
|
||||||
|
import { Head, Link, useForm } from '@inertiajs/react';
|
||||||
|
import { Users, ArrowLeft, Check, Lock, Mail, User } from 'lucide-react';
|
||||||
|
import { Button } from '@/Components/ui/button';
|
||||||
|
import { Input } from '@/Components/ui/input';
|
||||||
|
import { Label } from '@/Components/ui/label';
|
||||||
|
import { Checkbox } from '@/Components/ui/checkbox';
|
||||||
|
import { FormEvent } from 'react';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
roles: Record<string, string>; // ID -> Name map from pluck
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function UserCreate({ roles }: Props) {
|
||||||
|
const { data, setData, post, processing, errors } = useForm({
|
||||||
|
name: '',
|
||||||
|
email: '',
|
||||||
|
username: '',
|
||||||
|
password: '',
|
||||||
|
password_confirmation: '',
|
||||||
|
roles: [] as string[], // Role names
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSubmit = (e: FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
post(route('users.store'));
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleRole = (roleName: string) => {
|
||||||
|
if (data.roles.includes(roleName)) {
|
||||||
|
setData('roles', data.roles.filter(r => r !== roleName));
|
||||||
|
} else {
|
||||||
|
setData('roles', [...data.roles, roleName]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const translateRoleName = (name: string) => {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
'super-admin': '超級管理員',
|
||||||
|
'admin': '管理員',
|
||||||
|
'warehouse-manager': '倉庫主管',
|
||||||
|
'purchaser': '採購人員',
|
||||||
|
'viewer': '檢視者',
|
||||||
|
};
|
||||||
|
return map[name] || name;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthenticatedLayout
|
||||||
|
breadcrumbs={[
|
||||||
|
{ label: '系統管理', href: '#' },
|
||||||
|
{ label: '使用者管理', href: route('users.index') },
|
||||||
|
{ label: '新增使用者', href: route('users.create'), isPage: true },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Head title="新增使用者" />
|
||||||
|
|
||||||
|
<div className="p-8 max-w-4xl mx-auto">
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-8">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-grey-0 flex items-center gap-2">
|
||||||
|
<Users className="h-6 w-6 text-[#01ab83]" />
|
||||||
|
新增使用者
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-500 mt-1">
|
||||||
|
建立新帳號並設定初始密碼與角色
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Link href={route('users.index')}>
|
||||||
|
<Button variant="outline" type="button">
|
||||||
|
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
className="bg-[#01ab83] hover:bg-[#019a76]"
|
||||||
|
disabled={processing}
|
||||||
|
>
|
||||||
|
<Check className="h-4 w-4 mr-2" />
|
||||||
|
建立帳號
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||||
|
{/* Basic Info */}
|
||||||
|
<div className="md:col-span-2 space-y-6">
|
||||||
|
<div className="bg-white p-6 rounded-xl border border-gray-200 shadow-sm space-y-6">
|
||||||
|
<h3 className="font-bold text-gray-900 border-b pb-2 mb-4">基本資料</h3>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="name" className="flex items-center gap-2">
|
||||||
|
<User className="h-4 w-4" /> 姓名
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="name"
|
||||||
|
value={data.name}
|
||||||
|
onChange={e => setData('name', e.target.value)}
|
||||||
|
placeholder="例如:王小明"
|
||||||
|
/>
|
||||||
|
{errors.name && <p className="text-sm text-red-500">{errors.name}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="email" className="flex items-center gap-2">
|
||||||
|
<Mail className="h-4 w-4" /> 電子郵件 (選填)
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
value={data.email}
|
||||||
|
onChange={e => setData('email', e.target.value)}
|
||||||
|
placeholder="user@example.com (可省略)"
|
||||||
|
/>
|
||||||
|
{errors.email && <p className="text-sm text-red-500">{errors.email}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="username" className="flex items-center gap-2">
|
||||||
|
<User className="h-4 w-4" /> 使用者名稱 (登入用)
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="username"
|
||||||
|
value={data.username}
|
||||||
|
onChange={e => setData('username', e.target.value)}
|
||||||
|
placeholder="請輸入登入帳號"
|
||||||
|
/>
|
||||||
|
{errors.username && <p className="text-sm text-red-500">{errors.username}</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white p-6 rounded-xl border border-gray-200 shadow-sm space-y-6">
|
||||||
|
<h3 className="font-bold text-gray-900 border-b pb-2 mb-4">安全設定</h3>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="password" className="flex items-center gap-2">
|
||||||
|
<Lock className="h-4 w-4" /> 設定密碼
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
value={data.password}
|
||||||
|
onChange={e => setData('password', e.target.value)}
|
||||||
|
/>
|
||||||
|
{errors.password && <p className="text-sm text-red-500">{errors.password}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="password_confirmation" className="flex items-center gap-2">
|
||||||
|
<Lock className="h-4 w-4" /> 確認密碼
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="password_confirmation"
|
||||||
|
type="password"
|
||||||
|
value={data.password_confirmation}
|
||||||
|
onChange={e => setData('password_confirmation', e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Roles */}
|
||||||
|
<div className="md:col-span-1">
|
||||||
|
<div className="bg-white p-6 rounded-xl border border-gray-200 shadow-sm h-full">
|
||||||
|
<h3 className="font-bold text-gray-900 border-b pb-2 mb-4">角色分配</h3>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{Object.entries(roles).map(([id, name]) => (
|
||||||
|
<div key={id} className="flex items-start space-x-3 p-2 hover:bg-gray-50 rounded-lg transition-colors">
|
||||||
|
<Checkbox
|
||||||
|
id={`role-${id}`}
|
||||||
|
checked={data.roles.includes(name)}
|
||||||
|
onCheckedChange={() => toggleRole(name)}
|
||||||
|
/>
|
||||||
|
<div className="grid gap-1.5 leading-none">
|
||||||
|
<label
|
||||||
|
htmlFor={`role-${id}`}
|
||||||
|
className="text-sm font-medium leading-none cursor-pointer"
|
||||||
|
>
|
||||||
|
{translateRoleName(name)}
|
||||||
|
</label>
|
||||||
|
<p className="text-xs text-gray-500 font-mono">
|
||||||
|
{name}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{errors.roles && <p className="text-sm text-red-500">{errors.roles}</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</AuthenticatedLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
223
resources/js/Pages/Admin/User/Edit.tsx
Normal file
223
resources/js/Pages/Admin/User/Edit.tsx
Normal file
@@ -0,0 +1,223 @@
|
|||||||
|
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout';
|
||||||
|
import { Head, Link, useForm } from '@inertiajs/react';
|
||||||
|
import { Users, ArrowLeft, Check, Lock, Mail, User, AlertCircle } from 'lucide-react';
|
||||||
|
import { Button } from '@/Components/ui/button';
|
||||||
|
import { Input } from '@/Components/ui/input';
|
||||||
|
import { Label } from '@/Components/ui/label';
|
||||||
|
import { Checkbox } from '@/Components/ui/checkbox';
|
||||||
|
import { FormEvent } from 'react';
|
||||||
|
|
||||||
|
interface Role {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UserData {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
username: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
user: UserData;
|
||||||
|
roles: Role[];
|
||||||
|
currentRoles: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function UserEdit({ user, roles, currentRoles }: Props) {
|
||||||
|
const { data, setData, put, processing, errors } = useForm({
|
||||||
|
name: user.name,
|
||||||
|
email: user.email,
|
||||||
|
username: user.username || '',
|
||||||
|
password: '',
|
||||||
|
password_confirmation: '',
|
||||||
|
roles: currentRoles,
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSubmit = (e: FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
put(route('users.update', user.id));
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleRole = (roleName: string) => {
|
||||||
|
if (data.roles.includes(roleName)) {
|
||||||
|
setData('roles', data.roles.filter(r => r !== roleName));
|
||||||
|
} else {
|
||||||
|
setData('roles', [...data.roles, roleName]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const translateRoleName = (name: string) => {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
'super-admin': '超級管理員',
|
||||||
|
'admin': '管理員',
|
||||||
|
'warehouse-manager': '倉庫主管',
|
||||||
|
'purchaser': '採購人員',
|
||||||
|
'viewer': '檢視者',
|
||||||
|
};
|
||||||
|
return map[name] || name;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthenticatedLayout
|
||||||
|
breadcrumbs={[
|
||||||
|
{ label: '系統管理', href: '#' },
|
||||||
|
{ label: '使用者管理', href: route('users.index') },
|
||||||
|
{ label: '編輯使用者', href: route('users.edit', user.id), isPage: true },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Head title={`編輯使用者 - ${user.name}`} />
|
||||||
|
|
||||||
|
<div className="p-8 max-w-4xl mx-auto">
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-8">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-grey-0 flex items-center gap-2">
|
||||||
|
<Users className="h-6 w-6 text-[#01ab83]" />
|
||||||
|
編輯使用者
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-500 mt-1">
|
||||||
|
修改使用者資料、重設密碼或變更角色
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Link href={route('users.index')}>
|
||||||
|
<Button variant="outline" type="button">
|
||||||
|
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
className="bg-[#01ab83] hover:bg-[#019a76]"
|
||||||
|
disabled={processing}
|
||||||
|
>
|
||||||
|
<Check className="h-4 w-4 mr-2" />
|
||||||
|
儲存變更
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||||
|
{/* Basic Info */}
|
||||||
|
<div className="md:col-span-2 space-y-6">
|
||||||
|
<div className="bg-white p-6 rounded-xl border border-gray-200 shadow-sm space-y-6">
|
||||||
|
<h3 className="font-bold text-gray-900 border-b pb-2 mb-4">基本資料</h3>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="name" className="flex items-center gap-2">
|
||||||
|
<User className="h-4 w-4" /> 姓名
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="name"
|
||||||
|
value={data.name}
|
||||||
|
onChange={e => setData('name', e.target.value)}
|
||||||
|
placeholder="例如:王小明"
|
||||||
|
/>
|
||||||
|
{errors.name && <p className="text-sm text-red-500">{errors.name}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="email" className="flex items-center gap-2">
|
||||||
|
<Mail className="h-4 w-4" /> 電子郵件 (選填)
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
value={data.email}
|
||||||
|
onChange={e => setData('email', e.target.value)}
|
||||||
|
placeholder="user@example.com (可省略)"
|
||||||
|
/>
|
||||||
|
{errors.email && <p className="text-sm text-red-500">{errors.email}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="username" className="flex items-center gap-2">
|
||||||
|
<User className="h-4 w-4" /> 使用者名稱 (登入用)
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="username"
|
||||||
|
value={data.username}
|
||||||
|
onChange={e => setData('username', e.target.value)}
|
||||||
|
placeholder="請輸入登入帳號"
|
||||||
|
/>
|
||||||
|
{errors.username && <p className="text-sm text-red-500">{errors.username}</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white p-6 rounded-xl border border-gray-200 shadow-sm space-y-6">
|
||||||
|
<h3 className="font-bold text-gray-900 border-b pb-2 mb-4">重設密碼</h3>
|
||||||
|
<div className="bg-amber-50 text-amber-800 p-3 rounded-lg text-sm flex items-start gap-2 mb-4">
|
||||||
|
<AlertCircle className="h-4 w-4 mt-0.5 shrink-0" />
|
||||||
|
若不修改密碼,請留空以下欄位。
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="password" className="flex items-center gap-2">
|
||||||
|
<Lock className="h-4 w-4" /> 新密碼
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
value={data.password}
|
||||||
|
onChange={e => setData('password', e.target.value)}
|
||||||
|
placeholder="••••••••"
|
||||||
|
/>
|
||||||
|
{errors.password && <p className="text-sm text-red-500">{errors.password}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="password_confirmation" className="flex items-center gap-2">
|
||||||
|
<Lock className="h-4 w-4" /> 確認新密碼
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="password_confirmation"
|
||||||
|
type="password"
|
||||||
|
value={data.password_confirmation}
|
||||||
|
onChange={e => setData('password_confirmation', e.target.value)}
|
||||||
|
placeholder="••••••••"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Roles */}
|
||||||
|
<div className="md:col-span-1">
|
||||||
|
<div className="bg-white p-6 rounded-xl border border-gray-200 shadow-sm h-full">
|
||||||
|
<h3 className="font-bold text-gray-900 border-b pb-2 mb-4">角色分配</h3>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{roles.map((role) => (
|
||||||
|
<div key={role.id} className="flex items-start space-x-3 p-2 hover:bg-gray-50 rounded-lg transition-colors">
|
||||||
|
<Checkbox
|
||||||
|
id={`role-${role.id}`}
|
||||||
|
checked={data.roles.includes(role.name)}
|
||||||
|
onCheckedChange={() => toggleRole(role.name)}
|
||||||
|
// Prevent changing super-admin if user is editing themselves? Or just backend protection.
|
||||||
|
/>
|
||||||
|
<div className="grid gap-1.5 leading-none">
|
||||||
|
<label
|
||||||
|
htmlFor={`role-${role.id}`}
|
||||||
|
className="text-sm font-medium leading-none cursor-pointer"
|
||||||
|
>
|
||||||
|
{translateRoleName(role.name)}
|
||||||
|
</label>
|
||||||
|
<p className="text-xs text-gray-500 font-mono">
|
||||||
|
{role.name}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{errors.roles && <p className="text-sm text-red-500">{errors.roles}</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</AuthenticatedLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
223
resources/js/Pages/Admin/User/Index.tsx
Normal file
223
resources/js/Pages/Admin/User/Index.tsx
Normal file
@@ -0,0 +1,223 @@
|
|||||||
|
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout';
|
||||||
|
import { Head, Link, router } from '@inertiajs/react';
|
||||||
|
import { Users, Plus, Pencil, Trash2, Mail, Shield } from 'lucide-react';
|
||||||
|
import { Button } from '@/Components/ui/button';
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/Components/ui/table";
|
||||||
|
import { format } from 'date-fns';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
interface Role {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface User {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
username: string | null;
|
||||||
|
created_at: string;
|
||||||
|
roles: Role[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Pagination {
|
||||||
|
current_page: number;
|
||||||
|
last_page: number;
|
||||||
|
per_page: number;
|
||||||
|
total: number;
|
||||||
|
links: {
|
||||||
|
url: string | null;
|
||||||
|
label: string;
|
||||||
|
active: boolean;
|
||||||
|
}[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
users: {
|
||||||
|
data: User[];
|
||||||
|
meta?: Pagination; // Standard Laravel Pagination resource structure, but if simple paginate() it's direct properties
|
||||||
|
} & Pagination; // paginate() returns object with data and meta properties mixed
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function UserIndex({ users }: Props) {
|
||||||
|
const handleDelete = (id: number, name: string) => {
|
||||||
|
if (confirm(`確定要刪除使用者「${name}」嗎?此操作無法復原。`)) {
|
||||||
|
router.delete(route('users.destroy', id), {
|
||||||
|
onSuccess: () => toast.success('使用者已刪除'),
|
||||||
|
onError: () => toast.error('刪除失敗,請檢查權限'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const translateRoleName = (name: string) => {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
'super-admin': '超級管理員',
|
||||||
|
'admin': '管理員',
|
||||||
|
'warehouse-manager': '倉庫主管',
|
||||||
|
'purchaser': '採購人員',
|
||||||
|
'viewer': '檢視者',
|
||||||
|
};
|
||||||
|
return map[name] || name;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthenticatedLayout
|
||||||
|
breadcrumbs={[
|
||||||
|
{ label: '系統管理', href: '#' },
|
||||||
|
{ label: '使用者管理', href: route('users.index'), isPage: true },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Head title="使用者管理" />
|
||||||
|
|
||||||
|
<div className="p-8 max-w-7xl mx-auto space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-grey-0 flex items-center gap-2">
|
||||||
|
<Users className="h-6 w-6 text-[#01ab83]" />
|
||||||
|
使用者管理
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-500 mt-1">
|
||||||
|
管理系統使用者帳號與角色分配
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Link href={route('users.create')}>
|
||||||
|
<Button className="bg-[#01ab83] hover:bg-[#019a76]">
|
||||||
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
|
新增使用者
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden">
|
||||||
|
<Table>
|
||||||
|
<TableHeader className="bg-gray-50">
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="w-[250px]">使用者</TableHead>
|
||||||
|
<TableHead>角色</TableHead>
|
||||||
|
<TableHead className="w-[200px]">加入時間</TableHead>
|
||||||
|
<TableHead className="text-center">操作</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{users.data.map((user) => (
|
||||||
|
<TableRow key={user.id}>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="h-10 w-10 rounded-full bg-[#01ab83]/10 flex items-center justify-center text-[#01ab83] font-bold">
|
||||||
|
{user.name.charAt(0).toUpperCase()}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-gray-900">{user.name}</p>
|
||||||
|
<div className="flex items-center text-xs text-gray-500">
|
||||||
|
<Mail className="h-3 w-3 mr-1" />
|
||||||
|
{user.email}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{user.roles.length > 0 ? (
|
||||||
|
user.roles.map(role => (
|
||||||
|
<span
|
||||||
|
key={role.id}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center px-2 py-1 rounded-md text-xs font-medium border",
|
||||||
|
role.name === 'super-admin'
|
||||||
|
? "bg-purple-50 text-purple-700 border-purple-200"
|
||||||
|
: "bg-gray-100 text-gray-700 border-gray-200"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{role.name === 'super-admin' && <Shield className="h-3 w-3 mr-1" />}
|
||||||
|
{translateRoleName(role.name)}
|
||||||
|
</span>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<span className="text-gray-400 text-sm italic">未分配角色</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-gray-500 text-sm">
|
||||||
|
{format(new Date(user.created_at), 'yyyy/MM/dd')}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-center">
|
||||||
|
<div className="flex items-center justify-center gap-2">
|
||||||
|
<Link href={route('users.edit', user.id)}>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="button-outlined-primary h-8 w-8 p-0"
|
||||||
|
title="編輯"
|
||||||
|
>
|
||||||
|
<Pencil className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="button-outlined-error h-8 w-8 p-0"
|
||||||
|
title="刪除"
|
||||||
|
onClick={() => handleDelete(user.id, user.name)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
|
||||||
|
{/* Pagination - Simple implementation */}
|
||||||
|
{users.links && users.links.length > 3 && (
|
||||||
|
<div className="px-4 py-3 border-t border-gray-200 flex items-center justify-between sm:px-6">
|
||||||
|
<div className="flex-1 flex justify-between sm:hidden">
|
||||||
|
{/* Mobile pagination */}
|
||||||
|
</div>
|
||||||
|
<div className="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-700">
|
||||||
|
顯示第 <span className="font-medium">{users.current_page}</span> 頁
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<nav className="relative z-0 inline-flex rounded-md shadow-sm -space-x-px" aria-label="Pagination">
|
||||||
|
{users.links.map((link, i) => {
|
||||||
|
if (link.url === null) return null; // Skip null links usually
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={i}
|
||||||
|
href={link.url}
|
||||||
|
className={cn(
|
||||||
|
"relative inline-flex items-center px-4 py-2 border text-sm font-medium",
|
||||||
|
link.active
|
||||||
|
? "z-10 bg-[#01ab83] border-[#01ab83] text-white"
|
||||||
|
: "bg-white border-gray-300 text-gray-500 hover:bg-gray-50",
|
||||||
|
i === 0 ? "rounded-l-md" : "",
|
||||||
|
i === users.links.length - 1 ? "rounded-r-md" : ""
|
||||||
|
)}
|
||||||
|
dangerouslySetInnerHTML={{ __html: link.label }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AuthenticatedLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Helper for conditional class names if not imported
|
||||||
|
function cn(...classes: (string | undefined | null | false)[]) {
|
||||||
|
return classes.filter(Boolean).join(' ');
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@ import { useState, useEffect, useCallback } from "react";
|
|||||||
import { Button } from "@/Components/ui/button";
|
import { Button } from "@/Components/ui/button";
|
||||||
import { Input } from "@/Components/ui/input";
|
import { Input } from "@/Components/ui/input";
|
||||||
import { SearchableSelect } from "@/Components/ui/searchable-select";
|
import { SearchableSelect } from "@/Components/ui/searchable-select";
|
||||||
import { Plus, Search, X } from "lucide-react";
|
import { Plus, Search, Package, X } from 'lucide-react';
|
||||||
import ProductTable from "@/Components/Product/ProductTable";
|
import ProductTable from "@/Components/Product/ProductTable";
|
||||||
import ProductDialog from "@/Components/Product/ProductDialog";
|
import ProductDialog from "@/Components/Product/ProductDialog";
|
||||||
import CategoryManagerDialog from "@/Components/Category/CategoryManagerDialog";
|
import CategoryManagerDialog from "@/Components/Category/CategoryManagerDialog";
|
||||||
@@ -176,8 +176,11 @@ export default function ProductManagement({ products, categories, units, filters
|
|||||||
<div className="container mx-auto p-6 max-w-7xl">
|
<div className="container mx-auto p-6 max-w-7xl">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<h1 className="mb-2">商品資料管理</h1>
|
<h1 className="text-2xl font-bold text-grey-0 flex items-center gap-2">
|
||||||
<p className="text-gray-600">管理小小冰室原物料與成品資料</p>
|
<Package className="h-6 w-6 text-[#01ab83]" />
|
||||||
|
商品資料管理
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-500 mt-1">管理小小冰室原物料與成品資料</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Toolbar */}
|
{/* Toolbar */}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState, useCallback } from "react";
|
import { useState, useCallback } from "react";
|
||||||
import { Plus } from "lucide-react";
|
import { Plus, Search, X, ShoppingCart } from 'lucide-react';
|
||||||
import { Button } from "@/Components/ui/button";
|
import { Button } from "@/Components/ui/button";
|
||||||
import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout";
|
import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout";
|
||||||
import { Head, router } from "@inertiajs/react";
|
import { Head, router } from "@inertiajs/react";
|
||||||
@@ -92,16 +92,23 @@ export default function PurchaseOrderIndex({ orders, filters, warehouses }: Prop
|
|||||||
<div className="container mx-auto p-6 max-w-7xl">
|
<div className="container mx-auto p-6 max-w-7xl">
|
||||||
<div className="flex items-center justify-between mb-6">
|
<div className="flex items-center justify-between mb-6">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="mb-2">管理採購單</h1>
|
<h1 className="text-2xl font-bold text-grey-0 flex items-center gap-2">
|
||||||
<p className="text-gray-600">追蹤並管理所有倉庫的採購申請與進度</p>
|
<ShoppingCart className="h-6 w-6 text-[#01ab83]" />
|
||||||
|
採購單管理
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-500 mt-1">
|
||||||
|
建立與管理採購訂單,追蹤入庫狀態
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
onClick={handleNavigateToCreateOrder}
|
||||||
|
className="gap-2 button-filled-primary"
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
建立採購單
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
|
||||||
onClick={handleNavigateToCreateOrder}
|
|
||||||
className="gap-2 button-filled-primary"
|
|
||||||
>
|
|
||||||
<Plus className="h-4 w-4" />
|
|
||||||
建立採購單
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
|
|||||||
9
resources/js/Pages/Vendor/Index.tsx
vendored
9
resources/js/Pages/Vendor/Index.tsx
vendored
@@ -1,7 +1,7 @@
|
|||||||
import { useState, useEffect, useCallback } from "react";
|
import { useState, useEffect, useCallback } from "react";
|
||||||
import { Button } from "@/Components/ui/button";
|
import { Button } from "@/Components/ui/button";
|
||||||
import { Input } from "@/Components/ui/input";
|
import { Input } from "@/Components/ui/input";
|
||||||
import { Plus, Search, X } from "lucide-react";
|
import { Plus, Search, X, Contact2 } from "lucide-react";
|
||||||
import VendorTable from "@/Components/Vendor/VendorTable";
|
import VendorTable from "@/Components/Vendor/VendorTable";
|
||||||
import VendorDialog from "@/Components/Vendor/VendorDialog";
|
import VendorDialog from "@/Components/Vendor/VendorDialog";
|
||||||
import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout";
|
import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout";
|
||||||
@@ -130,8 +130,11 @@ export default function VendorManagement({ vendors, filters }: PageProps) {
|
|||||||
<div className="container mx-auto p-6 max-w-7xl">
|
<div className="container mx-auto p-6 max-w-7xl">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<h1 className="mb-2">廠商資料管理</h1>
|
<h1 className="text-2xl font-bold text-grey-0 flex items-center gap-2">
|
||||||
<p className="text-gray-600">管理 ERP 系統供應商與聯絡資訊</p>
|
<Contact2 className="h-6 w-6 text-[#01ab83]" />
|
||||||
|
廠商資料管理
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-500 mt-1">管理 ERP 系統供應商與聯絡資訊</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Toolbar */}
|
{/* Toolbar */}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Plus } from "lucide-react";
|
import { Plus, Warehouse as WarehouseIcon } from 'lucide-react';
|
||||||
import { Button } from "@/Components/ui/button";
|
import { Button } from "@/Components/ui/button";
|
||||||
import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout";
|
import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout";
|
||||||
import { Head, router } from "@inertiajs/react";
|
import { Head, router } from "@inertiajs/react";
|
||||||
@@ -35,9 +35,6 @@ export default function WarehouseIndex({ warehouses, filters }: PageProps) {
|
|||||||
const [editingWarehouse, setEditingWarehouse] = useState<Warehouse | null>(null);
|
const [editingWarehouse, setEditingWarehouse] = useState<Warehouse | null>(null);
|
||||||
const [transferOrderDialogOpen, setTransferOrderDialogOpen] = useState(false);
|
const [transferOrderDialogOpen, setTransferOrderDialogOpen] = useState(false);
|
||||||
|
|
||||||
// 暫時的 Mock Inventories,直到後端 API 實作
|
|
||||||
|
|
||||||
|
|
||||||
// 搜尋處理
|
// 搜尋處理
|
||||||
const handleSearch = (term: string) => {
|
const handleSearch = (term: string) => {
|
||||||
setSearchTerm(term);
|
setSearchTerm(term);
|
||||||
@@ -49,7 +46,7 @@ export default function WarehouseIndex({ warehouses, filters }: PageProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// 導航處理
|
// 導航處理
|
||||||
const handleViewInventory = (warehouseId: string) => {
|
const handleViewInventory = (warehouseId: string | number) => {
|
||||||
router.get(`/warehouses/${warehouseId}/inventory`);
|
router.get(`/warehouses/${warehouseId}/inventory`);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -77,16 +74,14 @@ export default function WarehouseIndex({ warehouses, filters }: PageProps) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteWarehouse = (id: string) => {
|
const handleDeleteWarehouse = (id: string | number) => {
|
||||||
router.delete(route('warehouses.destroy', id), {
|
router.delete(route('warehouses.destroy', id), {
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast.success('倉庫已刪除');
|
toast.success('倉庫已刪除');
|
||||||
setEditingWarehouse(null);
|
setEditingWarehouse(null);
|
||||||
},
|
},
|
||||||
onError: (errors: any) => {
|
onError: (errors: any) => {
|
||||||
// If backend returns error bag or flash error
|
console.error(errors);
|
||||||
// Flash error is handled by AuthenticatedLayout usually via usePage props.
|
|
||||||
// But we can also check errors bag here if needed.
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -114,8 +109,13 @@ export default function WarehouseIndex({ warehouses, filters }: PageProps) {
|
|||||||
<div className="container mx-auto p-6 max-w-7xl">
|
<div className="container mx-auto p-6 max-w-7xl">
|
||||||
{/* 頁面標題 */}
|
{/* 頁面標題 */}
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<h1 className="mb-2">倉庫管理</h1>
|
<h1 className="text-2xl font-bold text-grey-0 flex items-center gap-2">
|
||||||
<p className="text-gray-600 font-medium mb-4">管理倉庫資訊與庫存配置</p>
|
<WarehouseIcon className="h-6 w-6 text-[#01ab83]" />
|
||||||
|
倉庫管理
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-500 mt-1">
|
||||||
|
管理倉庫地點、負責人與庫存監控
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 工具列 */}
|
{/* 工具列 */}
|
||||||
|
|||||||
77
resources/js/hooks/usePermission.ts
Normal file
77
resources/js/hooks/usePermission.ts
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import { usePage } from '@inertiajs/react';
|
||||||
|
import { PageProps } from '@/types/global';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 權限判斷 Hook
|
||||||
|
* 提供權限與角色檢查功能
|
||||||
|
*/
|
||||||
|
export function usePermission() {
|
||||||
|
const { auth } = usePage<PageProps>().props;
|
||||||
|
const user = auth.user;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 檢查使用者是否擁有指定權限
|
||||||
|
*/
|
||||||
|
const can = (permission: string): boolean => {
|
||||||
|
if (!user) return false;
|
||||||
|
return user.permissions.includes(permission);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 檢查使用者是否擁有任一指定權限
|
||||||
|
*/
|
||||||
|
const canAny = (permissions: string[]): boolean => {
|
||||||
|
if (!user) return false;
|
||||||
|
return permissions.some(p => user.permissions.includes(p));
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 檢查使用者是否擁有所有指定權限
|
||||||
|
*/
|
||||||
|
const canAll = (permissions: string[]): boolean => {
|
||||||
|
if (!user) return false;
|
||||||
|
return permissions.every(p => user.permissions.includes(p));
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 檢查使用者是否擁有指定角色
|
||||||
|
*/
|
||||||
|
const hasRole = (role: string): boolean => {
|
||||||
|
if (!user) return false;
|
||||||
|
return user.roles.includes(role);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 檢查使用者是否擁有任一指定角色
|
||||||
|
*/
|
||||||
|
const hasAnyRole = (roles: string[]): boolean => {
|
||||||
|
if (!user) return false;
|
||||||
|
return roles.some(r => user.roles.includes(r));
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 檢查使用者是否擁有所有指定角色
|
||||||
|
*/
|
||||||
|
const hasAllRoles = (roles: string[]): boolean => {
|
||||||
|
if (!user) return false;
|
||||||
|
return roles.every(r => user.roles.includes(r));
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 檢查使用者是否為超級管理員
|
||||||
|
*/
|
||||||
|
const isSuperAdmin = (): boolean => {
|
||||||
|
return hasRole('super-admin');
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
can,
|
||||||
|
canAny,
|
||||||
|
canAll,
|
||||||
|
hasRole,
|
||||||
|
hasAnyRole,
|
||||||
|
hasAllRoles,
|
||||||
|
isSuperAdmin,
|
||||||
|
user
|
||||||
|
};
|
||||||
|
}
|
||||||
19
resources/js/types/global.d.ts
vendored
19
resources/js/types/global.d.ts
vendored
@@ -1,6 +1,25 @@
|
|||||||
import { AxiosInstance } from 'axios';
|
import { AxiosInstance } from 'axios';
|
||||||
import { route as routeFn } from 'ziggy-js';
|
import { route as routeFn } from 'ziggy-js';
|
||||||
|
|
||||||
|
export interface AuthUser {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
username?: string;
|
||||||
|
roles: string[];
|
||||||
|
permissions: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PageProps {
|
||||||
|
auth: {
|
||||||
|
user: AuthUser | null;
|
||||||
|
};
|
||||||
|
flash: {
|
||||||
|
success?: string;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
axios: AxiosInstance;
|
axios: AxiosInstance;
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ use App\Http\Controllers\InventoryController;
|
|||||||
use App\Http\Controllers\SafetyStockController;
|
use App\Http\Controllers\SafetyStockController;
|
||||||
use App\Http\Controllers\TransferOrderController;
|
use App\Http\Controllers\TransferOrderController;
|
||||||
use App\Http\Controllers\UnitController;
|
use App\Http\Controllers\UnitController;
|
||||||
|
use App\Http\Controllers\Admin\RoleController;
|
||||||
|
use App\Http\Controllers\Admin\UserController;
|
||||||
|
|
||||||
Route::get('/login', [LoginController::class, 'show'])->name('login');
|
Route::get('/login', [LoginController::class, 'show'])->name('login');
|
||||||
Route::post('/login', [LoginController::class, 'store']);
|
Route::post('/login', [LoginController::class, 'store']);
|
||||||
@@ -85,4 +87,10 @@ Route::middleware('auth')->group(function () {
|
|||||||
Route::post('/transfer-orders', [TransferOrderController::class, 'store'])->name('transfer-orders.store');
|
Route::post('/transfer-orders', [TransferOrderController::class, 'store'])->name('transfer-orders.store');
|
||||||
Route::get('/api/warehouses/{warehouse}/inventories', [TransferOrderController::class, 'getWarehouseInventories'])->name('api.warehouses.inventories');
|
Route::get('/api/warehouses/{warehouse}/inventories', [TransferOrderController::class, 'getWarehouseInventories'])->name('api.warehouses.inventories');
|
||||||
|
|
||||||
|
// 系統管理
|
||||||
|
Route::prefix('admin')->group(function () {
|
||||||
|
Route::resource('roles', RoleController::class);
|
||||||
|
Route::resource('users', UserController::class);
|
||||||
|
});
|
||||||
|
|
||||||
}); // End of auth middleware group
|
}); // End of auth middleware group
|
||||||
|
|||||||
Reference in New Issue
Block a user