From ecfcbb93ed0aa4c0195ba0c0e321763a1a06ab72 Mon Sep 17 00:00:00 2001 From: sky121113 Date: Tue, 13 Jan 2026 13:30:51 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=8C=E6=88=90=E6=AC=8A=E9=99=90?= =?UTF-8?q?=E7=AE=A1=E7=90=86=E7=B3=BB=E7=B5=B1=E3=80=81=E7=B5=B1=E4=B8=80?= =?UTF-8?q?=E9=A0=81=E9=9D=A2=E6=A8=99=E9=A1=8C=E6=A8=A3=E5=BC=8F=E8=88=87?= =?UTF-8?q?=E8=A1=A8=E6=A0=BC=E5=B0=8D=E9=BD=8A=E8=A6=8F=E7=AF=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/Http/Controllers/Admin/RoleController.php | 170 +++++++++++++ app/Http/Controllers/Admin/UserController.php | 136 +++++++++++ app/Http/Middleware/HandleInertiaRequests.php | 12 +- app/Models/User.php | 3 +- composer.json | 1 + composer.lock | 85 ++++++- config/permission.php | 202 ++++++++++++++++ ..._01_13_113720_create_permission_tables.php | 134 +++++++++++ database/seeders/PermissionSeeder.php | 122 ++++++++++ package-lock.json | 11 + package.json | 1 + resources/js/Components/Permission/Can.tsx | 84 +++++++ .../PurchaseOrder/PurchaseOrderActions.tsx | 2 +- .../PurchaseOrder/PurchaseOrderTable.tsx | 4 +- resources/js/Layouts/AuthenticatedLayout.tsx | 24 +- resources/js/Pages/Admin/Role/Create.tsx | 197 ++++++++++++++++ resources/js/Pages/Admin/Role/Edit.tsx | 210 +++++++++++++++++ resources/js/Pages/Admin/Role/Index.tsx | 150 ++++++++++++ resources/js/Pages/Admin/User/Create.tsx | 202 ++++++++++++++++ resources/js/Pages/Admin/User/Edit.tsx | 223 ++++++++++++++++++ resources/js/Pages/Admin/User/Index.tsx | 223 ++++++++++++++++++ resources/js/Pages/Product/Index.tsx | 9 +- resources/js/Pages/PurchaseOrder/Index.tsx | 27 ++- resources/js/Pages/Vendor/Index.tsx | 9 +- resources/js/Pages/Warehouse/Index.tsx | 22 +- resources/js/hooks/usePermission.ts | 77 ++++++ resources/js/types/global.d.ts | 19 ++ routes/web.php | 8 + 28 files changed, 2333 insertions(+), 34 deletions(-) create mode 100644 app/Http/Controllers/Admin/RoleController.php create mode 100644 app/Http/Controllers/Admin/UserController.php create mode 100644 config/permission.php create mode 100644 database/migrations/2026_01_13_113720_create_permission_tables.php create mode 100644 database/seeders/PermissionSeeder.php create mode 100644 resources/js/Components/Permission/Can.tsx create mode 100644 resources/js/Pages/Admin/Role/Create.tsx create mode 100644 resources/js/Pages/Admin/Role/Edit.tsx create mode 100644 resources/js/Pages/Admin/Role/Index.tsx create mode 100644 resources/js/Pages/Admin/User/Create.tsx create mode 100644 resources/js/Pages/Admin/User/Edit.tsx create mode 100644 resources/js/Pages/Admin/User/Index.tsx create mode 100644 resources/js/hooks/usePermission.ts diff --git a/app/Http/Controllers/Admin/RoleController.php b/app/Http/Controllers/Admin/RoleController.php new file mode 100644 index 0000000..a986c9b --- /dev/null +++ b/app/Http/Controllers/Admin/RoleController.php @@ -0,0 +1,170 @@ +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; + } +} diff --git a/app/Http/Controllers/Admin/UserController.php b/app/Http/Controllers/Admin/UserController.php new file mode 100644 index 0000000..85f1d1f --- /dev/null +++ b/app/Http/Controllers/Admin/UserController.php @@ -0,0 +1,136 @@ +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', '使用者已刪除'); + } +} diff --git a/app/Http/Middleware/HandleInertiaRequests.php b/app/Http/Middleware/HandleInertiaRequests.php index e0ab02a..3e13687 100644 --- a/app/Http/Middleware/HandleInertiaRequests.php +++ b/app/Http/Middleware/HandleInertiaRequests.php @@ -35,10 +35,20 @@ class HandleInertiaRequests extends Middleware */ public function share(Request $request): array { + $user = $request->user(); + return [ ...parent::share($request), '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' => [ 'success' => $request->session()->get('success'), diff --git a/app/Models/User.php b/app/Models/User.php index b48779b..0e4d418 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -6,11 +6,12 @@ namespace App\Models; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; +use Spatie\Permission\Traits\HasRoles; class User extends Authenticatable { /** @use HasFactory<\Database\Factories\UserFactory> */ - use HasFactory, Notifiable; + use HasFactory, Notifiable, HasRoles; /** * The attributes that are mass assignable. diff --git a/composer.json b/composer.json index 2774593..e3ca062 100644 --- a/composer.json +++ b/composer.json @@ -13,6 +13,7 @@ "inertiajs/inertia-laravel": "^2.0", "laravel/framework": "^12.0", "laravel/tinker": "^2.10.1", + "spatie/laravel-permission": "^6.24", "tightenco/ziggy": "^2.6" }, "require-dev": { diff --git a/composer.lock b/composer.lock index 651e649..fe204f0 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "56c0c203f0c7715d0a0f4d3d36b1932c", + "content-hash": "edfbf8e1cc43c925ea8b04fc1f93da65", "packages": [ { "name": "brick/math", @@ -3360,6 +3360,89 @@ }, "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", "version": "v8.0.0", diff --git a/config/permission.php b/config/permission.php new file mode 100644 index 0000000..f39f6b5 --- /dev/null +++ b/config/permission.php @@ -0,0 +1,202 @@ + [ + + /* + * 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', + ], +]; diff --git a/database/migrations/2026_01_13_113720_create_permission_tables.php b/database/migrations/2026_01_13_113720_create_permission_tables.php new file mode 100644 index 0000000..66ce1f9 --- /dev/null +++ b/database/migrations/2026_01_13_113720_create_permission_tables.php @@ -0,0 +1,134 @@ +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']); + } +}; diff --git a/database/seeders/PermissionSeeder.php b/database/seeders/PermissionSeeder.php new file mode 100644 index 0000000..2d3b167 --- /dev/null +++ b/database/seeders/PermissionSeeder.php @@ -0,0 +1,122 @@ +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"); + } + } +} diff --git a/package-lock.json b/package-lock.json index 552bb85..b11002c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,6 +22,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", + "date-fns": "^4.1.0", "jsbarcode": "^3.12.1", "lodash": "^4.17.21", "lucide-react": "^0.562.0", @@ -2844,6 +2845,16 @@ "devOptional": true, "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": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", diff --git a/package.json b/package.json index 9b42722..23bec74 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", + "date-fns": "^4.1.0", "jsbarcode": "^3.12.1", "lodash": "^4.17.21", "lucide-react": "^0.562.0", diff --git a/resources/js/Components/Permission/Can.tsx b/resources/js/Components/Permission/Can.tsx new file mode 100644 index 0000000..98bb38a --- /dev/null +++ b/resources/js/Components/Permission/Can.tsx @@ -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 + * + * + * + * + * + *
管理操作
+ *
+ * ``` + */ +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 + * + * 管理後台 + * + * + * + * + * + * ``` + */ +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 + * + * + * + * ``` + */ +export function CanAll({ permissions, children, fallback = null }: CanAllProps) { + const { canAll } = usePermission(); + + return canAll(permissions) ? <>{children} : <>{fallback}; +} diff --git a/resources/js/Components/PurchaseOrder/PurchaseOrderActions.tsx b/resources/js/Components/PurchaseOrder/PurchaseOrderActions.tsx index 16ec08a..9e350dc 100644 --- a/resources/js/Components/PurchaseOrder/PurchaseOrderActions.tsx +++ b/resources/js/Components/PurchaseOrder/PurchaseOrderActions.tsx @@ -20,7 +20,7 @@ export function PurchaseOrderActions({ }; return ( -
+
- 操作 + 操作 @@ -214,7 +214,7 @@ export default function PurchaseOrderTable({ - + diff --git a/resources/js/Layouts/AuthenticatedLayout.tsx b/resources/js/Layouts/AuthenticatedLayout.tsx index 9b6464d..1a709ce 100644 --- a/resources/js/Layouts/AuthenticatedLayout.tsx +++ b/resources/js/Layouts/AuthenticatedLayout.tsx @@ -13,7 +13,10 @@ import { FileText, LogOut, User, - ChevronDown + ChevronDown, + Settings, + Shield, + Users } from "lucide-react"; import { toast, Toaster } from "sonner"; import { useState, useEffect } from "react"; @@ -101,6 +104,25 @@ export default function AuthenticatedLayout({ }, ], }, + { + id: "system-management", + label: "系統管理", + icon: , + children: [ + { + id: "user-management", + label: "使用者管理", + icon: , + route: "/admin/users", + }, + { + id: "role-management", + label: "角色與權限", + icon: , + route: "/admin/roles", + }, + ], + }, ]; // 初始化狀態:優先讀取 localStorage diff --git a/resources/js/Pages/Admin/Role/Create.tsx b/resources/js/Pages/Admin/Role/Create.tsx new file mode 100644 index 0000000..7c6f65a --- /dev/null +++ b/resources/js/Pages/Admin/Role/Create.tsx @@ -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 = { + 'view': '檢視', + 'create': '新增', + 'edit': '編輯', + 'delete': '刪除', + 'publish': '發布', + 'adjust': '調整', + 'transfer': '調撥', + }; + + return map[action] || action; + }; + + return ( + + + +
+
+ {/* Header */} +
+
+

+ + 建立新角色 +

+

+ 設定角色名稱並分配對應的操作權限 +

+
+
+ + + + +
+
+ + {/* Role Name */} +
+
+
+ + setData('name', e.target.value)} + className="font-mono" + /> + {errors.name && ( +

{errors.name}

+ )} +

+ 請使用英文字母與連字號,例如: warehouse-staff +

+
+
+
+ + {/* Permissions Matrix */} +
+

權限設定

+
+ {groupedPermissions.map((group) => { + const allGroupSelected = group.permissions.every(p => data.permissions.includes(p.name)); + + return ( +
+
+ {group.name} + +
+
+
+ {group.permissions.map((permission) => ( +
+ togglePermission(permission.name)} + /> +
+ +

+ {permission.name} +

+
+
+ ))} +
+
+
+ ); + })} +
+
+
+
+
+ ); +} diff --git a/resources/js/Pages/Admin/Role/Edit.tsx b/resources/js/Pages/Admin/Role/Edit.tsx new file mode 100644 index 0000000..2ca836a --- /dev/null +++ b/resources/js/Pages/Admin/Role/Edit.tsx @@ -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 = { + 'view': '檢視', + 'create': '新增', + 'edit': '編輯', + 'delete': '刪除', + 'publish': '發布', + 'adjust': '調整', + 'transfer': '調撥', + }; + + return map[action] || action; + }; + + return ( + + + +
+
+ {/* Header */} +
+
+

+ + 編輯角色 +

+

+ 修改角色資料與權限設定 +

+
+
+ + + + +
+
+ + {/* Role Name */} +
+
+
+ + 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 && ( +

{errors.name}

+ )} + {role.name === 'super-admin' ? ( +
+ + 超級管理員角色名稱不可修改 +
+ ) : ( +

+ 請使用英文字母與連字號,例如: warehouse-staff +

+ )} +
+
+
+ + {/* Permissions Matrix */} +
+

權限設定

+
+ {groupedPermissions.map((group) => { + const allGroupSelected = group.permissions.every(p => data.permissions.includes(p.name)); + + return ( +
+
+ {group.name} + +
+
+
+ {group.permissions.map((permission) => ( +
+ togglePermission(permission.name)} + /> +
+ +

+ {permission.name} +

+
+
+ ))} +
+
+
+ ); + })} +
+
+
+
+
+ ); +} diff --git a/resources/js/Pages/Admin/Role/Index.tsx b/resources/js/Pages/Admin/Role/Index.tsx new file mode 100644 index 0000000..be3e8e5 --- /dev/null +++ b/resources/js/Pages/Admin/Role/Index.tsx @@ -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 = { + 'super-admin': '超級管理員', + 'admin': '管理員', + 'warehouse-manager': '倉庫主管', + 'purchaser': '採購人員', + 'viewer': '檢視者', + }; + return map[name] || name; + } + + return ( + + + +
+
+
+

+ + 角色與權限 +

+

+ 設定系統角色與功能存取權限 +

+
+ + + +
+ +
+ + + + 角色名稱 + 代號 + 權限數量 + 使用者人數 + 建立時間 + 操作 + + + + {roles.map((role) => ( + + +
+
+ +
+ {translateRoleName(role.name)} +
+
+ + {role.name} + + + + {role.permissions_count} 項權限 + + + +
+ + {role.users_count} +
+
+ + {format(new Date(role.created_at), 'yyyy/MM/dd')} + + + {role.name !== 'super-admin' && ( +
+ + + + +
+ )} +
+
+ ))} +
+
+
+
+
+ ); +} diff --git a/resources/js/Pages/Admin/User/Create.tsx b/resources/js/Pages/Admin/User/Create.tsx new file mode 100644 index 0000000..882ab75 --- /dev/null +++ b/resources/js/Pages/Admin/User/Create.tsx @@ -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; // 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 = { + 'super-admin': '超級管理員', + 'admin': '管理員', + 'warehouse-manager': '倉庫主管', + 'purchaser': '採購人員', + 'viewer': '檢視者', + }; + return map[name] || name; + } + + return ( + + + +
+
+ {/* Header */} +
+
+

+ + 新增使用者 +

+

+ 建立新帳號並設定初始密碼與角色 +

+
+
+ + + + +
+
+ +
+ {/* Basic Info */} +
+
+

基本資料

+ +
+ + setData('name', e.target.value)} + placeholder="例如:王小明" + /> + {errors.name &&

{errors.name}

} +
+ +
+ + setData('email', e.target.value)} + placeholder="user@example.com (可省略)" + /> + {errors.email &&

{errors.email}

} +
+ +
+ + setData('username', e.target.value)} + placeholder="請輸入登入帳號" + /> + {errors.username &&

{errors.username}

} +
+
+ +
+

安全設定

+ +
+
+ + setData('password', e.target.value)} + /> + {errors.password &&

{errors.password}

} +
+ +
+ + setData('password_confirmation', e.target.value)} + /> +
+
+
+
+ + {/* Roles */} +
+
+

角色分配

+
+ {Object.entries(roles).map(([id, name]) => ( +
+ toggleRole(name)} + /> +
+ +

+ {name} +

+
+
+ ))} + {errors.roles &&

{errors.roles}

} +
+
+
+
+
+
+
+ ); +} diff --git a/resources/js/Pages/Admin/User/Edit.tsx b/resources/js/Pages/Admin/User/Edit.tsx new file mode 100644 index 0000000..78b8b66 --- /dev/null +++ b/resources/js/Pages/Admin/User/Edit.tsx @@ -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 = { + 'super-admin': '超級管理員', + 'admin': '管理員', + 'warehouse-manager': '倉庫主管', + 'purchaser': '採購人員', + 'viewer': '檢視者', + }; + return map[name] || name; + } + + return ( + + + +
+
+ {/* Header */} +
+
+

+ + 編輯使用者 +

+

+ 修改使用者資料、重設密碼或變更角色 +

+
+
+ + + + +
+
+ +
+ {/* Basic Info */} +
+
+

基本資料

+ +
+ + setData('name', e.target.value)} + placeholder="例如:王小明" + /> + {errors.name &&

{errors.name}

} +
+ +
+ + setData('email', e.target.value)} + placeholder="user@example.com (可省略)" + /> + {errors.email &&

{errors.email}

} +
+ +
+ + setData('username', e.target.value)} + placeholder="請輸入登入帳號" + /> + {errors.username &&

{errors.username}

} +
+
+ +
+

重設密碼

+
+ + 若不修改密碼,請留空以下欄位。 +
+ +
+
+ + setData('password', e.target.value)} + placeholder="••••••••" + /> + {errors.password &&

{errors.password}

} +
+ +
+ + setData('password_confirmation', e.target.value)} + placeholder="••••••••" + /> +
+
+
+
+ + {/* Roles */} +
+
+

角色分配

+
+ {roles.map((role) => ( +
+ toggleRole(role.name)} + // Prevent changing super-admin if user is editing themselves? Or just backend protection. + /> +
+ +

+ {role.name} +

+
+
+ ))} + {errors.roles &&

{errors.roles}

} +
+
+
+
+
+
+
+ ); +} diff --git a/resources/js/Pages/Admin/User/Index.tsx b/resources/js/Pages/Admin/User/Index.tsx new file mode 100644 index 0000000..952ecf9 --- /dev/null +++ b/resources/js/Pages/Admin/User/Index.tsx @@ -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 = { + 'super-admin': '超級管理員', + 'admin': '管理員', + 'warehouse-manager': '倉庫主管', + 'purchaser': '採購人員', + 'viewer': '檢視者', + }; + return map[name] || name; + } + + return ( + + + +
+
+
+

+ + 使用者管理 +

+

+ 管理系統使用者帳號與角色分配 +

+
+ + + +
+ +
+ + + + 使用者 + 角色 + 加入時間 + 操作 + + + + {users.data.map((user) => ( + + +
+
+ {user.name.charAt(0).toUpperCase()} +
+
+

{user.name}

+
+ + {user.email} +
+
+
+
+ +
+ {user.roles.length > 0 ? ( + user.roles.map(role => ( + + {role.name === 'super-admin' && } + {translateRoleName(role.name)} + + )) + ) : ( + 未分配角色 + )} +
+
+ + {format(new Date(user.created_at), 'yyyy/MM/dd')} + + +
+ + + + +
+
+
+ ))} +
+
+ + {/* Pagination - Simple implementation */} + {users.links && users.links.length > 3 && ( +
+
+ {/* Mobile pagination */} +
+
+
+

+ 顯示第 {users.current_page} 頁 +

+
+
+ +
+
+
+ )} +
+
+
+ ); +} +// Helper for conditional class names if not imported +function cn(...classes: (string | undefined | null | false)[]) { + return classes.filter(Boolean).join(' '); +} diff --git a/resources/js/Pages/Product/Index.tsx b/resources/js/Pages/Product/Index.tsx index 6aa9478..cb8f0f7 100644 --- a/resources/js/Pages/Product/Index.tsx +++ b/resources/js/Pages/Product/Index.tsx @@ -2,7 +2,7 @@ import { useState, useEffect, useCallback } from "react"; import { Button } from "@/Components/ui/button"; import { Input } from "@/Components/ui/input"; 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 ProductDialog from "@/Components/Product/ProductDialog"; import CategoryManagerDialog from "@/Components/Category/CategoryManagerDialog"; @@ -176,8 +176,11 @@ export default function ProductManagement({ products, categories, units, filters
{/* Header */}
-

商品資料管理

-

管理小小冰室原物料與成品資料

+

+ + 商品資料管理 +

+

管理小小冰室原物料與成品資料

{/* Toolbar */} diff --git a/resources/js/Pages/PurchaseOrder/Index.tsx b/resources/js/Pages/PurchaseOrder/Index.tsx index c07be75..f291f89 100644 --- a/resources/js/Pages/PurchaseOrder/Index.tsx +++ b/resources/js/Pages/PurchaseOrder/Index.tsx @@ -3,7 +3,7 @@ */ 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 AuthenticatedLayout from "@/Layouts/AuthenticatedLayout"; import { Head, router } from "@inertiajs/react"; @@ -92,16 +92,23 @@ export default function PurchaseOrderIndex({ orders, filters, warehouses }: Prop
-

管理採購單

-

追蹤並管理所有倉庫的採購申請與進度

+

+ + 採購單管理 +

+

+ 建立與管理採購訂單,追蹤入庫狀態 +

+
+
+
-
diff --git a/resources/js/Pages/Vendor/Index.tsx b/resources/js/Pages/Vendor/Index.tsx index e2c0544..640d4a8 100644 --- a/resources/js/Pages/Vendor/Index.tsx +++ b/resources/js/Pages/Vendor/Index.tsx @@ -1,7 +1,7 @@ import { useState, useEffect, useCallback } from "react"; import { Button } from "@/Components/ui/button"; 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 VendorDialog from "@/Components/Vendor/VendorDialog"; import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout"; @@ -130,8 +130,11 @@ export default function VendorManagement({ vendors, filters }: PageProps) {
{/* Header */}
-

廠商資料管理

-

管理 ERP 系統供應商與聯絡資訊

+

+ + 廠商資料管理 +

+

管理 ERP 系統供應商與聯絡資訊

{/* Toolbar */} diff --git a/resources/js/Pages/Warehouse/Index.tsx b/resources/js/Pages/Warehouse/Index.tsx index 7aa3d92..d6af568 100644 --- a/resources/js/Pages/Warehouse/Index.tsx +++ b/resources/js/Pages/Warehouse/Index.tsx @@ -1,5 +1,5 @@ import { useState } from "react"; -import { Plus } from "lucide-react"; +import { Plus, Warehouse as WarehouseIcon } from 'lucide-react'; import { Button } from "@/Components/ui/button"; import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout"; import { Head, router } from "@inertiajs/react"; @@ -35,9 +35,6 @@ export default function WarehouseIndex({ warehouses, filters }: PageProps) { const [editingWarehouse, setEditingWarehouse] = useState(null); const [transferOrderDialogOpen, setTransferOrderDialogOpen] = useState(false); - // 暫時的 Mock Inventories,直到後端 API 實作 - - // 搜尋處理 const handleSearch = (term: string) => { 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`); }; @@ -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), { onSuccess: () => { toast.success('倉庫已刪除'); setEditingWarehouse(null); }, onError: (errors: any) => { - // If backend returns error bag or flash error - // Flash error is handled by AuthenticatedLayout usually via usePage props. - // But we can also check errors bag here if needed. + console.error(errors); } }); }; @@ -114,8 +109,13 @@ export default function WarehouseIndex({ warehouses, filters }: PageProps) {
{/* 頁面標題 */}
-

倉庫管理

-

管理倉庫資訊與庫存配置

+

+ + 倉庫管理 +

+

+ 管理倉庫地點、負責人與庫存監控 +

{/* 工具列 */} diff --git a/resources/js/hooks/usePermission.ts b/resources/js/hooks/usePermission.ts new file mode 100644 index 0000000..b6e35e2 --- /dev/null +++ b/resources/js/hooks/usePermission.ts @@ -0,0 +1,77 @@ +import { usePage } from '@inertiajs/react'; +import { PageProps } from '@/types/global'; + +/** + * 權限判斷 Hook + * 提供權限與角色檢查功能 + */ +export function usePermission() { + const { auth } = usePage().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 + }; +} diff --git a/resources/js/types/global.d.ts b/resources/js/types/global.d.ts index 79ae427..3854a8b 100644 --- a/resources/js/types/global.d.ts +++ b/resources/js/types/global.d.ts @@ -1,6 +1,25 @@ import { AxiosInstance } from 'axios'; 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 { interface Window { axios: AxiosInstance; diff --git a/routes/web.php b/routes/web.php index 81f471d..5b05b2e 100644 --- a/routes/web.php +++ b/routes/web.php @@ -14,6 +14,8 @@ use App\Http\Controllers\InventoryController; use App\Http\Controllers\SafetyStockController; use App\Http\Controllers\TransferOrderController; 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::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::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