diff --git a/.agent/skills/permission-management/SKILL.md b/.agent/skills/permission-management/SKILL.md
new file mode 100644
index 0000000..c7e3661
--- /dev/null
+++ b/.agent/skills/permission-management/SKILL.md
@@ -0,0 +1,119 @@
+---
+name: 權限管理與實作規範
+description: 為新功能實作權限控制的完整流程規範,包含後端 Seeder 設定、Middleware 路由保護與前端權限判斷。
+---
+
+# 權限管理與實作規範
+
+本文件說明如何在新增功能時,一併實作完整的權限控制機制。專案採用 `spatie/laravel-permission` 套件進行權限管理。
+
+## 1. 定義權限 (Backend)
+
+所有權限皆定義於 `database/seeders/PermissionSeeder.php`。
+
+### 步驟:
+
+1. 開啟 `database/seeders/PermissionSeeder.php`。
+2. 在 `$permissions` 陣列中新增功能對應的權限字串。
+ * **命名慣例**:`{resource}.{action}` (例如:`system.view_logs`, `products.create`)
+ * 常用動作:`view`, `create`, `edit`, `delete`, `publish`, `export`
+3. 在下方「角色分配」區段,將新權限分配給適合的角色。
+ * `super-admin`:通常擁有所有權限(程式碼中 `Permission::all()` 自動涵蓋,無需手動新增)。
+ * `admin`:通常擁有大部分權限。
+ * 其他角色 (`warehouse-manager`, `purchaser`, `viewer`):依業務邏輯分配。
+
+### 範例:
+
+```php
+// 1. 新增權限字串
+$permissions = [
+ // ... 現有權限
+ 'system.view_logs', // 新增:檢視系統日誌
+];
+
+// ...
+
+// 2. 分配給角色
+$admin->givePermissionTo([
+ // ... 現有權限
+ 'system.view_logs',
+]);
+```
+
+## 2. 套用資料庫變更
+
+修改 Seeder 後,必須重新執行 Seeder 以將權限寫入資料庫。
+
+```bash
+# 對於所有租戶執行 Seeder (開發環境)
+php artisan tenants:seed --class=PermissionSeeder
+```
+
+## 3. 路由保護 (Backend Middleware)
+
+在 `routes/web.php` 中,使用 `permission:{name}` middleware 保護路由。
+
+### 範例:
+
+```php
+// 單一權限保護
+Route::get('/logs', [LogController::class, 'index'])
+ ->middleware('permission:system.view_logs')
+ ->name('logs.index');
+
+// 路由群組保護
+Route::middleware('permission:products.view')->group(function () {
+ // ...
+});
+
+// 多重權限 (OR 邏輯:有其一即可)
+Route::middleware('permission:products.create|products.edit')->group(function () {
+ // ...
+});
+```
+
+## 4. 前端權限判斷 (React Component)
+
+使用自訂 Hook `usePermission` 來控制 UI 元素的顯示(例如:隱藏沒有權限的按鈕)。
+
+### 引入 Hook:
+
+```tsx
+import { usePermission } from "@/hooks/usePermission";
+```
+
+### 使用方式:
+
+```tsx
+export default function ProductIndex() {
+ const { can } = usePermission();
+
+ return (
+
+
商品列表
+
+ {/* 只有擁有 create 權限才顯示按鈕 */}
+ {can('products.create') && (
+
+ )}
+
+ {/* 組合判斷 */}
+ {can('products.edit') && }
+
+ );
+}
+```
+
+### 權限 Hook 介面說明:
+
+- `can(permission: string)`: 檢查當前使用者是否擁有指定權限。
+- `canAny(permissions: string[])`: 檢查當前使用者是否擁有陣列中**任一**權限。
+- `hasRole(role: string)`: 檢查當前使用者是否擁有指定角色。
+
+## 檢核清單
+
+- [ ] `PermissionSeeder.php` 已新增權限字串。
+- [ ] `PermissionSeeder.php` 已將新權限分配給對應角色。
+- [ ] 已執行 `php artisan tenants:seed --class=PermissionSeeder` 更新資料庫。
+- [ ] 後端路由 (`routes/web.php`) 已加上 middleware 保護。
+- [ ] 前端頁面/按鈕已使用 `usePermission` 進行顯示控制。
diff --git a/app/Http/Controllers/Admin/ActivityLogController.php b/app/Http/Controllers/Admin/ActivityLogController.php
new file mode 100644
index 0000000..5897dba
--- /dev/null
+++ b/app/Http/Controllers/Admin/ActivityLogController.php
@@ -0,0 +1,52 @@
+latest()
+ ->paginate($request->input('per_page', 10))
+ ->through(function ($activity) {
+ $subjectMap = [
+ 'App\Models\User' => '使用者',
+ 'App\Models\Role' => '角色',
+ 'App\Models\Product' => '商品',
+ 'App\Models\Vendor' => '廠商',
+ 'App\Models\Category' => '商品分類',
+ 'App\Models\Unit' => '單位',
+ 'App\Models\PurchaseOrder' => '採購單',
+ ];
+
+ $eventMap = [
+ 'created' => '新增',
+ 'updated' => '更新',
+ 'deleted' => '刪除',
+ ];
+
+ return [
+ 'id' => $activity->id,
+ 'description' => $eventMap[$activity->event] ?? $activity->event,
+ 'subject_type' => $subjectMap[$activity->subject_type] ?? class_basename($activity->subject_type),
+ 'event' => $activity->event,
+ 'causer' => $activity->causer ? $activity->causer->name : 'System',
+ 'created_at' => $activity->created_at->format('Y-m-d H:i:s'),
+ 'properties' => $activity->properties,
+ ];
+ });
+
+ return Inertia::render('Admin/ActivityLog/Index', [
+ 'activities' => $activities,
+ 'filters' => [
+ 'per_page' => $request->input('per_page', '10'),
+ ],
+ ]);
+ }
+}
diff --git a/app/Models/Category.php b/app/Models/Category.php
index a10777e..4e1f2e2 100644
--- a/app/Models/Category.php
+++ b/app/Models/Category.php
@@ -5,10 +5,11 @@ namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
+use Spatie\Activitylog\Traits\LogsActivity;
class Category extends Model
{
- use HasFactory;
+ use HasFactory, LogsActivity;
protected $fillable = [
'name',
@@ -27,4 +28,12 @@ class Category extends Model
{
return $this->hasMany(Product::class);
}
+
+ public function getActivitylogOptions(): \Spatie\Activitylog\LogOptions
+ {
+ return \Spatie\Activitylog\LogOptions::defaults()
+ ->logAll()
+ ->logOnlyDirty()
+ ->dontSubmitEmptyLogs();
+ }
}
diff --git a/app/Models/Product.php b/app/Models/Product.php
index 6d63bef..ef269c7 100644
--- a/app/Models/Product.php
+++ b/app/Models/Product.php
@@ -6,10 +6,13 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
+use Illuminate\Database\Eloquent\Relations\HasMany;
+use Spatie\Activitylog\Traits\LogsActivity;
+use Spatie\Activitylog\LogOptions;
class Product extends Model
{
- use HasFactory, SoftDeletes;
+ use HasFactory, LogsActivity, SoftDeletes;
protected $fillable = [
'code',
@@ -60,6 +63,19 @@ class Product extends Model
return $this->hasMany(Inventory::class);
}
+ public function transactions(): HasMany
+ {
+ return $this->hasMany(InventoryTransaction::class);
+ }
+
+ public function getActivitylogOptions(): LogOptions
+ {
+ return LogOptions::defaults()
+ ->logAll()
+ ->logOnlyDirty()
+ ->dontSubmitEmptyLogs();
+ }
+
public function warehouses(): \Illuminate\Database\Eloquent\Relations\BelongsToMany
{
return $this->belongsToMany(Warehouse::class, 'inventories')
diff --git a/app/Models/PurchaseOrder.php b/app/Models/PurchaseOrder.php
index d6d7213..d4acc7b 100644
--- a/app/Models/PurchaseOrder.php
+++ b/app/Models/PurchaseOrder.php
@@ -6,10 +6,12 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
+use Spatie\Activitylog\Traits\LogsActivity;
+use Spatie\Activitylog\LogOptions;
class PurchaseOrder extends Model
{
- use HasFactory;
+ use HasFactory, LogsActivity;
protected $fillable = [
'code',
@@ -125,4 +127,12 @@ class PurchaseOrder extends Model
{
return $this->hasMany(PurchaseOrderItem::class);
}
+
+ public function getActivitylogOptions(): LogOptions
+ {
+ return LogOptions::defaults()
+ ->logAll()
+ ->logOnlyDirty()
+ ->dontSubmitEmptyLogs();
+ }
}
diff --git a/app/Models/Role.php b/app/Models/Role.php
new file mode 100644
index 0000000..4fd89e5
--- /dev/null
+++ b/app/Models/Role.php
@@ -0,0 +1,20 @@
+logAll()
+ ->logOnlyDirty()
+ ->dontSubmitEmptyLogs();
+ }
+}
diff --git a/app/Models/Unit.php b/app/Models/Unit.php
index bcfddfd..00a1cc5 100644
--- a/app/Models/Unit.php
+++ b/app/Models/Unit.php
@@ -4,14 +4,23 @@ namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
+use Spatie\Activitylog\Traits\LogsActivity;
class Unit extends Model
{
/** @use HasFactory<\Database\Factories\UnitFactory> */
- use HasFactory;
+ use HasFactory, LogsActivity;
protected $fillable = [
'name',
'code',
];
+
+ public function getActivitylogOptions(): \Spatie\Activitylog\LogOptions
+ {
+ return \Spatie\Activitylog\LogOptions::defaults()
+ ->logAll()
+ ->logOnlyDirty()
+ ->dontSubmitEmptyLogs();
+ }
}
diff --git a/app/Models/User.php b/app/Models/User.php
index 0e4d418..6b85850 100644
--- a/app/Models/User.php
+++ b/app/Models/User.php
@@ -7,11 +7,13 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Spatie\Permission\Traits\HasRoles;
+use Spatie\Activitylog\Traits\LogsActivity;
+use Spatie\Activitylog\LogOptions;
class User extends Authenticatable
{
/** @use HasFactory<\Database\Factories\UserFactory> */
- use HasFactory, Notifiable, HasRoles;
+ use HasFactory, Notifiable, HasRoles, LogsActivity;
/**
* The attributes that are mass assignable.
@@ -47,4 +49,12 @@ class User extends Authenticatable
'password' => 'hashed',
];
}
+
+ public function getActivitylogOptions(): LogOptions
+ {
+ return LogOptions::defaults()
+ ->logAll()
+ ->logOnlyDirty()
+ ->dontSubmitEmptyLogs();
+ }
}
diff --git a/app/Models/Vendor.php b/app/Models/Vendor.php
index 14f4256..6e0be0d 100644
--- a/app/Models/Vendor.php
+++ b/app/Models/Vendor.php
@@ -5,9 +5,13 @@ namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
+use Spatie\Activitylog\Traits\LogsActivity;
+use Spatie\Activitylog\LogOptions;
class Vendor extends Model
{
+ use LogsActivity;
+
protected $fillable = [
'code',
'name',
@@ -32,4 +36,12 @@ class Vendor extends Model
{
return $this->hasMany(PurchaseOrder::class);
}
+
+ public function getActivitylogOptions(): LogOptions
+ {
+ return LogOptions::defaults()
+ ->logAll()
+ ->logOnlyDirty()
+ ->dontSubmitEmptyLogs();
+ }
}
diff --git a/composer.json b/composer.json
index b04034e..92237b2 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-activitylog": "^4.10",
"spatie/laravel-permission": "^6.24",
"stancl/tenancy": "^3.9",
"tightenco/ziggy": "^2.6"
diff --git a/composer.lock b/composer.lock
index 2c30e85..deb4f73 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": "931b01f076d9ee28568cd36f178a0c04",
+ "content-hash": "131ea6e8cc24a6a55229afded6bd9014",
"packages": [
{
"name": "brick/math",
@@ -3413,6 +3413,158 @@
},
"time": "2025-12-14T04:43:48+00:00"
},
+ {
+ "name": "spatie/laravel-activitylog",
+ "version": "4.10.2",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/spatie/laravel-activitylog.git",
+ "reference": "bb879775d487438ed9a99e64f09086b608990c10"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/spatie/laravel-activitylog/zipball/bb879775d487438ed9a99e64f09086b608990c10",
+ "reference": "bb879775d487438ed9a99e64f09086b608990c10",
+ "shasum": ""
+ },
+ "require": {
+ "illuminate/config": "^8.0 || ^9.0 || ^10.0 || ^11.0 || ^12.0",
+ "illuminate/database": "^8.69 || ^9.27 || ^10.0 || ^11.0 || ^12.0",
+ "illuminate/support": "^8.0 || ^9.0 || ^10.0 || ^11.0 || ^12.0",
+ "php": "^8.1",
+ "spatie/laravel-package-tools": "^1.6.3"
+ },
+ "require-dev": {
+ "ext-json": "*",
+ "orchestra/testbench": "^6.23 || ^7.0 || ^8.0 || ^9.0 || ^10.0",
+ "pestphp/pest": "^1.20 || ^2.0 || ^3.0"
+ },
+ "type": "library",
+ "extra": {
+ "laravel": {
+ "providers": [
+ "Spatie\\Activitylog\\ActivitylogServiceProvider"
+ ]
+ }
+ },
+ "autoload": {
+ "files": [
+ "src/helpers.php"
+ ],
+ "psr-4": {
+ "Spatie\\Activitylog\\": "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"
+ },
+ {
+ "name": "Sebastian De Deyne",
+ "email": "sebastian@spatie.be",
+ "homepage": "https://spatie.be",
+ "role": "Developer"
+ },
+ {
+ "name": "Tom Witkowski",
+ "email": "dev.gummibeer@gmail.com",
+ "homepage": "https://gummibeer.de",
+ "role": "Developer"
+ }
+ ],
+ "description": "A very simple activity logger to monitor the users of your website or application",
+ "homepage": "https://github.com/spatie/activitylog",
+ "keywords": [
+ "activity",
+ "laravel",
+ "log",
+ "spatie",
+ "user"
+ ],
+ "support": {
+ "issues": "https://github.com/spatie/laravel-activitylog/issues",
+ "source": "https://github.com/spatie/laravel-activitylog/tree/4.10.2"
+ },
+ "funding": [
+ {
+ "url": "https://spatie.be/open-source/support-us",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/spatie",
+ "type": "github"
+ }
+ ],
+ "time": "2025-06-15T06:59:49+00:00"
+ },
+ {
+ "name": "spatie/laravel-package-tools",
+ "version": "1.92.7",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/spatie/laravel-package-tools.git",
+ "reference": "f09a799850b1ed765103a4f0b4355006360c49a5"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/spatie/laravel-package-tools/zipball/f09a799850b1ed765103a4f0b4355006360c49a5",
+ "reference": "f09a799850b1ed765103a4f0b4355006360c49a5",
+ "shasum": ""
+ },
+ "require": {
+ "illuminate/contracts": "^9.28|^10.0|^11.0|^12.0",
+ "php": "^8.0"
+ },
+ "require-dev": {
+ "mockery/mockery": "^1.5",
+ "orchestra/testbench": "^7.7|^8.0|^9.0|^10.0",
+ "pestphp/pest": "^1.23|^2.1|^3.1",
+ "phpunit/php-code-coverage": "^9.0|^10.0|^11.0",
+ "phpunit/phpunit": "^9.5.24|^10.5|^11.5",
+ "spatie/pest-plugin-test-time": "^1.1|^2.2"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Spatie\\LaravelPackageTools\\": "src"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Freek Van der Herten",
+ "email": "freek@spatie.be",
+ "role": "Developer"
+ }
+ ],
+ "description": "Tools for creating Laravel packages",
+ "homepage": "https://github.com/spatie/laravel-package-tools",
+ "keywords": [
+ "laravel-package-tools",
+ "spatie"
+ ],
+ "support": {
+ "issues": "https://github.com/spatie/laravel-package-tools/issues",
+ "source": "https://github.com/spatie/laravel-package-tools/tree/1.92.7"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/spatie",
+ "type": "github"
+ }
+ ],
+ "time": "2025-07-17T15:46:43+00:00"
+ },
{
"name": "spatie/laravel-permission",
"version": "6.24.0",
diff --git a/config/activitylog.php b/config/activitylog.php
new file mode 100644
index 0000000..f1262f5
--- /dev/null
+++ b/config/activitylog.php
@@ -0,0 +1,52 @@
+ env('ACTIVITY_LOGGER_ENABLED', true),
+
+ /*
+ * When the clean-command is executed, all recording activities older than
+ * the number of days specified here will be deleted.
+ */
+ 'delete_records_older_than_days' => 365,
+
+ /*
+ * If no log name is passed to the activity() helper
+ * we use this default log name.
+ */
+ 'default_log_name' => 'default',
+
+ /*
+ * You can specify an auth driver here that gets user models.
+ * If this is null we'll use the current Laravel auth driver.
+ */
+ 'default_auth_driver' => null,
+
+ /*
+ * If set to true, the subject returns soft deleted models.
+ */
+ 'subject_returns_soft_deleted_models' => false,
+
+ /*
+ * This model will be used to log activity.
+ * It should implement the Spatie\Activitylog\Contracts\Activity interface
+ * and extend Illuminate\Database\Eloquent\Model.
+ */
+ 'activity_model' => \Spatie\Activitylog\Models\Activity::class,
+
+ /*
+ * This is the name of the table that will be created by the migration and
+ * used by the Activity model shipped with this package.
+ */
+ 'table_name' => env('ACTIVITY_LOGGER_TABLE_NAME', 'activity_log'),
+
+ /*
+ * This is the database connection that will be used by the migration and
+ * the Activity model shipped with this package. In case it's not set
+ * Laravel's database.default will be used instead.
+ */
+ 'database_connection' => env('ACTIVITY_LOGGER_DB_CONNECTION'),
+];
diff --git a/config/permission.php b/config/permission.php
index f39f6b5..03bf909 100644
--- a/config/permission.php
+++ b/config/permission.php
@@ -24,7 +24,7 @@ return [
* `Spatie\Permission\Contracts\Role` contract.
*/
- 'role' => Spatie\Permission\Models\Role::class,
+ 'role' => App\Models\Role::class,
],
diff --git a/database/migrations/tenant/2026_01_16_152857_create_activity_log_table.php b/database/migrations/tenant/2026_01_16_152857_create_activity_log_table.php
new file mode 100644
index 0000000..7c05bc8
--- /dev/null
+++ b/database/migrations/tenant/2026_01_16_152857_create_activity_log_table.php
@@ -0,0 +1,27 @@
+create(config('activitylog.table_name'), function (Blueprint $table) {
+ $table->bigIncrements('id');
+ $table->string('log_name')->nullable();
+ $table->text('description');
+ $table->nullableMorphs('subject', 'subject');
+ $table->nullableMorphs('causer', 'causer');
+ $table->json('properties')->nullable();
+ $table->timestamps();
+ $table->index('log_name');
+ });
+ }
+
+ public function down()
+ {
+ Schema::connection(config('activitylog.database_connection'))->dropIfExists(config('activitylog.table_name'));
+ }
+}
diff --git a/database/migrations/tenant/2026_01_16_152858_add_event_column_to_activity_log_table.php b/database/migrations/tenant/2026_01_16_152858_add_event_column_to_activity_log_table.php
new file mode 100644
index 0000000..7b797fd
--- /dev/null
+++ b/database/migrations/tenant/2026_01_16_152858_add_event_column_to_activity_log_table.php
@@ -0,0 +1,22 @@
+table(config('activitylog.table_name'), function (Blueprint $table) {
+ $table->string('event')->nullable()->after('subject_type');
+ });
+ }
+
+ public function down()
+ {
+ Schema::connection(config('activitylog.database_connection'))->table(config('activitylog.table_name'), function (Blueprint $table) {
+ $table->dropColumn('event');
+ });
+ }
+}
diff --git a/database/migrations/tenant/2026_01_16_152859_add_batch_uuid_column_to_activity_log_table.php b/database/migrations/tenant/2026_01_16_152859_add_batch_uuid_column_to_activity_log_table.php
new file mode 100644
index 0000000..8f7db66
--- /dev/null
+++ b/database/migrations/tenant/2026_01_16_152859_add_batch_uuid_column_to_activity_log_table.php
@@ -0,0 +1,22 @@
+table(config('activitylog.table_name'), function (Blueprint $table) {
+ $table->uuid('batch_uuid')->nullable()->after('properties');
+ });
+ }
+
+ public function down()
+ {
+ Schema::connection(config('activitylog.database_connection'))->table(config('activitylog.table_name'), function (Blueprint $table) {
+ $table->dropColumn('batch_uuid');
+ });
+ }
+}
diff --git a/database/seeders/PermissionSeeder.php b/database/seeders/PermissionSeeder.php
index 4ffdf3d..10419a8 100644
--- a/database/seeders/PermissionSeeder.php
+++ b/database/seeders/PermissionSeeder.php
@@ -60,6 +60,9 @@ class PermissionSeeder extends Seeder
'roles.create',
'roles.edit',
'roles.delete',
+
+ // 系統日誌
+ 'system.view_logs',
];
foreach ($permissions as $permission) {
@@ -87,6 +90,7 @@ class PermissionSeeder extends Seeder
'vendors.view', 'vendors.create', 'vendors.edit', 'vendors.delete',
'warehouses.view', 'warehouses.create', 'warehouses.edit', 'warehouses.delete',
'users.view', 'users.create', 'users.edit',
+ 'system.view_logs',
]);
// warehouse-manager 管理庫存與倉庫
diff --git a/resources/js/Layouts/AuthenticatedLayout.tsx b/resources/js/Layouts/AuthenticatedLayout.tsx
index b9cb3aa..7e0a9c9 100644
--- a/resources/js/Layouts/AuthenticatedLayout.tsx
+++ b/resources/js/Layouts/AuthenticatedLayout.tsx
@@ -16,7 +16,8 @@ import {
ChevronDown,
Settings,
Shield,
- Users
+ Users,
+ FileText
} from "lucide-react";
import { toast, Toaster } from "sonner";
import { useState, useEffect, useMemo } from "react";
@@ -145,6 +146,13 @@ export default function AuthenticatedLayout({
route: "/admin/roles",
permission: "roles.view",
},
+ {
+ id: "activity-log",
+ label: "操作紀錄",
+ icon: ,
+ route: "/admin/activity-logs",
+ permission: "system.view_logs",
+ },
],
},
];
diff --git a/resources/js/Pages/Admin/ActivityLog/ActivityDetailDialog.tsx b/resources/js/Pages/Admin/ActivityLog/ActivityDetailDialog.tsx
new file mode 100644
index 0000000..f5808a9
--- /dev/null
+++ b/resources/js/Pages/Admin/ActivityLog/ActivityDetailDialog.tsx
@@ -0,0 +1,142 @@
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+} from "@/Components/ui/dialog";
+import { Badge } from "@/Components/ui/badge";
+import { ScrollArea } from "@/Components/ui/scroll-area";
+
+interface Activity {
+ id: number;
+ description: string;
+ subject_type: string;
+ event: string;
+ causer: string;
+ created_at: string;
+ properties: {
+ attributes?: Record;
+ old?: Record;
+ };
+}
+
+interface Props {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ activity: Activity | null;
+}
+
+export default function ActivityDetailDialog({ open, onOpenChange, activity }: Props) {
+ if (!activity) return null;
+
+ const attributes = activity.properties?.attributes || {};
+ const old = activity.properties?.old || {};
+
+ // Get all keys from both attributes and old to ensure we show all changes
+ const allKeys = Array.from(new Set([...Object.keys(attributes), ...Object.keys(old)]));
+
+ // Filter out internal keys often logged but not useful for users
+ const filteredKeys = allKeys.filter(key =>
+ !['created_at', 'updated_at', 'deleted_at', 'id'].includes(key)
+ );
+
+ const getEventBadgeColor = (event: string) => {
+ switch (event) {
+ case 'created': return 'bg-green-500';
+ case 'updated': return 'bg-blue-500';
+ case 'deleted': return 'bg-red-500';
+ default: return 'bg-gray-500';
+ }
+ };
+
+ const getEventLabel = (event: string) => {
+ switch (event) {
+ case 'created': return '新增';
+ case 'updated': return '更新';
+ case 'deleted': return '刪除';
+ default: return event;
+ }
+ };
+
+ const formatValue = (value: any) => {
+ if (value === null || value === undefined) return -;
+ if (typeof value === 'boolean') return value ? '是' : '否';
+ if (typeof value === 'object') return JSON.stringify(value);
+ return String(value);
+ };
+
+ return (
+
+ );
+}
diff --git a/resources/js/Pages/Admin/ActivityLog/Index.tsx b/resources/js/Pages/Admin/ActivityLog/Index.tsx
new file mode 100644
index 0000000..3c9b052
--- /dev/null
+++ b/resources/js/Pages/Admin/ActivityLog/Index.tsx
@@ -0,0 +1,203 @@
+import { useState } from 'react';
+import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout';
+import { Head, router } from '@inertiajs/react';
+import { PageProps } from '@/types/global';
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/Components/ui/table";
+import { Badge } from "@/Components/ui/badge";
+import Pagination from '@/Components/shared/Pagination';
+import { SearchableSelect } from "@/Components/ui/searchable-select";
+import { FileText, Eye } from 'lucide-react';
+import { format } from 'date-fns';
+import { Button } from '@/Components/ui/button';
+import ActivityDetailDialog from './ActivityDetailDialog';
+
+interface Activity {
+ id: number;
+ description: string;
+ subject_type: string;
+ event: string;
+ causer: string;
+ created_at: string;
+ properties: any;
+}
+
+interface PaginationLinks {
+ url: string | null;
+ label: string;
+ active: boolean;
+}
+
+interface Props extends PageProps {
+ activities: {
+ data: Activity[];
+ links: PaginationLinks[];
+ current_page: number;
+ last_page: number;
+ total: number;
+ from: number;
+ };
+ filters: {
+ per_page?: string;
+ };
+}
+
+export default function ActivityLogIndex({ activities, filters }: Props) {
+ const [perPage, setPerPage] = useState(filters.per_page || "10");
+ const [selectedActivity, setSelectedActivity] = useState(null);
+ const [detailOpen, setDetailOpen] = useState(false);
+
+ const getEventBadgeColor = (event: string) => {
+ switch (event) {
+ case 'created': return 'bg-green-500 hover:bg-green-600';
+ case 'updated': return 'bg-blue-500 hover:bg-blue-600';
+ case 'deleted': return 'bg-red-500 hover:bg-red-600';
+ default: return 'bg-gray-500 hover:bg-gray-600';
+ }
+ };
+
+ const getEventLabel = (event: string) => {
+ switch (event) {
+ case 'created': return '新增';
+ case 'updated': return '更新';
+ case 'deleted': return '刪除';
+ default: return event;
+ }
+ };
+
+ const handleViewDetail = (activity: Activity) => {
+ setSelectedActivity(activity);
+ setDetailOpen(true);
+ };
+
+
+
+ const handlePerPageChange = (value: string) => {
+ setPerPage(value);
+ router.get(
+ route('activity-logs.index'),
+ { per_page: value },
+ { preserveState: false, replace: true, preserveScroll: true }
+ );
+ };
+
+ return (
+
+
+
+
+
+
+
+
+ 操作紀錄
+
+
+ 檢視系統內的所有操作活動,包含新增、修改與刪除紀錄
+
+
+
+
+
+
+
+
+ 時間
+ 操作人員
+ 動作
+ 對象
+ 描述
+ 操作
+
+
+
+ {activities.data.length > 0 ? (
+ activities.data.map((activity) => (
+
+
+ {activity.created_at}
+
+
+ {activity.causer}
+
+
+
+ {getEventLabel(activity.event)}
+
+
+
+
+ {activity.subject_type}
+
+
+
+
+ {activity.causer}
+ 執行了
+ {activity.description}
+ 動作
+
+
+
+
+
+
+ ))
+ ) : (
+
+
+ 尚無操作紀錄
+
+
+ )}
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/routes/web.php b/routes/web.php
index 02f6616..2771dbe 100644
--- a/routes/web.php
+++ b/routes/web.php
@@ -16,6 +16,7 @@ use App\Http\Controllers\TransferOrderController;
use App\Http\Controllers\UnitController;
use App\Http\Controllers\Admin\RoleController;
use App\Http\Controllers\Admin\UserController;
+use App\Http\Controllers\Admin\ActivityLogController;
use App\Http\Controllers\ProfileController;
use Stancl\Tenancy\Middleware\InitializeTenancyByDomainOrSubdomain;
@@ -147,8 +148,13 @@ Route::middleware('auth')->group(function () {
});
Route::get('/users/{user}/edit', [UserController::class, 'edit'])->middleware('permission:users.edit')->name('users.edit');
Route::put('/users/{user}', [UserController::class, 'update'])->middleware('permission:users.edit')->name('users.update');
+ Route::put('/users/{user}', [UserController::class, 'update'])->middleware('permission:users.edit')->name('users.update');
Route::delete('/users/{user}', [UserController::class, 'destroy'])->middleware('permission:users.delete')->name('users.destroy');
});
+
+ Route::middleware('permission:system.view_logs')->group(function () {
+ Route::get('/activity-logs', [ActivityLogController::class, 'index'])->name('activity-logs.index');
+ });
});
}); // End of auth middleware group