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 ( + + + + + 操作詳情 + + {getEventLabel(activity.event)} + + + + {activity.created_at} 由 {activity.causer} 執行的操作 + + + +
+
+
+ 操作對象: + {activity.subject_type} +
+
+ 描述: + {activity.description} +
+
+ + {activity.event === 'created' ? ( +
+ 已新增資料 (初始建立) +
+ ) : ( +
+
+
欄位
+
異動前
+
異動後
+
+ + {filteredKeys.length > 0 ? ( +
+ {filteredKeys.map((key) => { + const oldValue = old[key]; + const newValue = attributes[key]; + // Ensure we catch changes even if one value is missing/null + // For deleted events, newValue might be empty, so we just show oldValue + const isChanged = JSON.stringify(oldValue) !== JSON.stringify(newValue); + + return ( +
+
{key}
+
+ {formatValue(oldValue)} +
+
+ {activity.event === 'deleted' ? '-' : formatValue(newValue)} +
+
+ ); + })} +
+ ) : ( +
+ 無詳細異動內容 +
+ )} +
+
+ )} +
+
+
+ ); +} 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