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
+ *
+ 設定系統角色與功能存取權限 +
++ 管理系統使用者帳號與角色分配 +
++ 顯示第 {users.current_page} 頁 +
+管理小小冰室原物料與成品資料
+管理小小冰室原物料與成品資料
追蹤並管理所有倉庫的採購申請與進度
++ 建立與管理採購訂單,追蹤入庫狀態 +
+管理 ERP 系統供應商與聯絡資訊
+管理 ERP 系統供應商與聯絡資訊
管理倉庫資訊與庫存配置
++ 管理倉庫地點、負責人與庫存監控 +