Compare commits
2 Commits
19c2eeba7b
...
74417e2e31
| Author | SHA1 | Date | |
|---|---|---|---|
| 74417e2e31 | |||
| 0d7bb2758d |
119
.agent/skills/permission-management/SKILL.md
Normal file
119
.agent/skills/permission-management/SKILL.md
Normal file
@@ -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 (
|
||||||
|
<div>
|
||||||
|
<h1>商品列表</h1>
|
||||||
|
|
||||||
|
{/* 只有擁有 create 權限才顯示按鈕 */}
|
||||||
|
{can('products.create') && (
|
||||||
|
<Button>新增商品</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 組合判斷 */}
|
||||||
|
{can('products.edit') && <EditButton />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 權限 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` 進行顯示控制。
|
||||||
@@ -57,13 +57,30 @@ tooltip
|
|||||||
|
|
||||||
## 2. 色彩系統
|
## 2. 色彩系統
|
||||||
|
|
||||||
### 2.1 主題色 (Primary)
|
### 2.1 主題色 (Primary) - **動態租戶品牌色**
|
||||||
|
|
||||||
```css
|
> **注意**:主題色會根據租戶設定(Branding)動態改變,**嚴禁**在程式碼中 Hardcode 色碼(如 `#01ab83`)。
|
||||||
--primary-main: #01ab83; /* 主題綠色 - 主要操作、連結 */
|
> 請務必使用 Tailwind Utility Class 或 CSS 變數。
|
||||||
--primary-dark: #018a6a; /* 深綠色 - Hover 狀態 */
|
|
||||||
--primary-light: #33bc9a; /* 淺綠色 - 次要強調 */
|
| Tailwind Class | CSS Variable | 說明 |
|
||||||
--primary-lightest: #e6f7f3; /* 最淺綠色 - 背景、Active 狀態 */
|
|----------------|--------------|------|
|
||||||
|
| `*-primary-main` | `--primary-main` | **主色**:與租戶設定一致(預設綠色),用於主要按鈕、連結、強調文字 |
|
||||||
|
| `*-primary-dark` | `--primary-dark` | **深色**:系統自動計算,用於 Hover 狀態 |
|
||||||
|
| `*-primary-light` | `--primary-light` | **淺色**:系統自動計算,用於次要強調 |
|
||||||
|
| `*-primary-lightest` | `--primary-lightest` | **最淺色**:系統自動計算,用於背景底色、Active 狀態 |
|
||||||
|
|
||||||
|
**運作機制**:
|
||||||
|
`AuthenticatedLayout` 會根據後端回傳的 `branding` 資料,自動注入 CSS 變數覆寫預設值。
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// ✅ 正確:使用 Tailwind Class
|
||||||
|
<div className="text-primary-main">...</div>
|
||||||
|
|
||||||
|
// ✅ 正確:使用 CSS 變數 (自定義樣式時)
|
||||||
|
<div style={{ borderColor: 'var(--primary-main)' }}>...</div>
|
||||||
|
|
||||||
|
// ❌ 錯誤:寫死色碼 (會導致租戶無法換色)
|
||||||
|
<div className="text-[#01ab83]">...</div>
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2.2 灰階 (Grey Scale)
|
### 2.2 灰階 (Grey Scale)
|
||||||
@@ -341,6 +358,62 @@ import { Plus, Pencil, Trash2, Users } from 'lucide-react';
|
|||||||
- 序號欄使用 `text-gray-500 font-medium text-center`
|
- 序號欄使用 `text-gray-500 font-medium text-center`
|
||||||
- 操作欄使用 `flex items-center justify-center gap-2` 排列按鈕
|
- 操作欄使用 `flex items-center justify-center gap-2` 排列按鈕
|
||||||
|
|
||||||
|
### 5.4 欄位排序規範
|
||||||
|
|
||||||
|
當表格需要支援排序時,請遵循以下模式:
|
||||||
|
|
||||||
|
1. **圖標邏輯**:
|
||||||
|
* 未排序:`ArrowUpDown` (class: `text-muted-foreground`)
|
||||||
|
* 升冪 (asc):`ArrowUp` (class: `text-primary`)
|
||||||
|
* 降冪 (desc):`ArrowDown` (class: `text-primary`)
|
||||||
|
2. **結構**:在 `TableHead` 內使用 `button` 元素。
|
||||||
|
3. **後端配合**:後端 Controller **必須** 處理 `sort_by` 與 `sort_order` 參數。
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// 1. 定義 Helper Component (在元件內部)
|
||||||
|
const SortIcon = ({ field }: { field: string }) => {
|
||||||
|
if (filters.sort_by !== field) {
|
||||||
|
return <ArrowUpDown className="h-4 w-4 text-muted-foreground ml-1" />;
|
||||||
|
}
|
||||||
|
if (filters.sort_order === "asc") {
|
||||||
|
return <ArrowUp className="h-4 w-4 text-primary ml-1" />;
|
||||||
|
}
|
||||||
|
return <ArrowDown className="h-4 w-4 text-primary ml-1" />;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 2. 表格標題應用
|
||||||
|
<TableHead>
|
||||||
|
<button
|
||||||
|
onClick={() => handleSort('created_at')}
|
||||||
|
className="flex items-center hover:text-gray-900"
|
||||||
|
>
|
||||||
|
建立時間 <SortIcon field="created_at" />
|
||||||
|
</button>
|
||||||
|
</TableHead>
|
||||||
|
|
||||||
|
// 3. 排序處理函式 (三態切換:未排序 -> 升冪 -> 降冪 -> 未排序)
|
||||||
|
const handleSort = (field: string) => {
|
||||||
|
let newSortBy: string | undefined = field;
|
||||||
|
let newSortOrder: 'asc' | 'desc' | undefined = 'asc';
|
||||||
|
|
||||||
|
if (filters.sort_by === field) {
|
||||||
|
if (filters.sort_order === 'asc') {
|
||||||
|
newSortOrder = 'desc';
|
||||||
|
} else {
|
||||||
|
// desc -> reset (回到預設排序)
|
||||||
|
newSortBy = undefined;
|
||||||
|
newSortOrder = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
route(route().current()!),
|
||||||
|
{ ...filters, sort_by: newSortBy, sort_order: newSortOrder },
|
||||||
|
{ preserveState: true, replace: true }
|
||||||
|
);
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 6. 分頁規範
|
## 6. 分頁規範
|
||||||
|
|||||||
64
app/Http/Controllers/Admin/ActivityLogController.php
Normal file
64
app/Http/Controllers/Admin/ActivityLogController.php
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Admin;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Inertia\Inertia;
|
||||||
|
use Spatie\Activitylog\Models\Activity;
|
||||||
|
|
||||||
|
class ActivityLogController extends Controller
|
||||||
|
{
|
||||||
|
public function index(Request $request)
|
||||||
|
{
|
||||||
|
$perPage = $request->input('per_page', 10);
|
||||||
|
$sortBy = $request->input('sort_by', 'created_at');
|
||||||
|
$sortOrder = $request->input('sort_order', 'desc');
|
||||||
|
|
||||||
|
$query = Activity::with('causer');
|
||||||
|
|
||||||
|
if ($sortBy === 'created_at') {
|
||||||
|
$query->orderBy($sortBy, $sortOrder);
|
||||||
|
} else {
|
||||||
|
$query->latest();
|
||||||
|
}
|
||||||
|
|
||||||
|
$activities = $query->paginate($perPage)
|
||||||
|
->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'),
|
||||||
|
'sort_by' => $request->input('sort_by'),
|
||||||
|
'sort_order' => $request->input('sort_order'),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,15 +14,26 @@ class RoleController extends Controller
|
|||||||
/**
|
/**
|
||||||
* Display a listing of the resource.
|
* Display a listing of the resource.
|
||||||
*/
|
*/
|
||||||
public function index()
|
public function index(Request $request)
|
||||||
{
|
{
|
||||||
$roles = Role::withCount('users', 'permissions')
|
$sortBy = $request->input('sort_by', 'id');
|
||||||
->with('users:id,name,username')
|
$sortOrder = $request->input('sort_order', 'asc');
|
||||||
->orderBy('id')
|
|
||||||
->get();
|
$query = Role::withCount('users', 'permissions')
|
||||||
|
->with('users:id,name,username');
|
||||||
|
|
||||||
|
// Handle sorting
|
||||||
|
if (in_array($sortBy, ['users_count', 'permissions_count', 'created_at', 'id'])) {
|
||||||
|
$query->orderBy($sortBy, $sortOrder);
|
||||||
|
} else {
|
||||||
|
$query->orderBy('id', 'asc');
|
||||||
|
}
|
||||||
|
|
||||||
|
$roles = $query->get();
|
||||||
|
|
||||||
return Inertia::render('Admin/Role/Index', [
|
return Inertia::render('Admin/Role/Index', [
|
||||||
'roles' => $roles
|
'roles' => $roles,
|
||||||
|
'filters' => $request->only(['sort_by', 'sort_order']),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,15 +18,23 @@ class UserController extends Controller
|
|||||||
public function index(Request $request)
|
public function index(Request $request)
|
||||||
{
|
{
|
||||||
$perPage = $request->input('per_page', 10);
|
$perPage = $request->input('per_page', 10);
|
||||||
|
$sortBy = $request->input('sort_by', 'id');
|
||||||
|
$sortOrder = $request->input('sort_order', 'asc');
|
||||||
|
|
||||||
$users = User::with(['roles:id,name,display_name'])
|
$query = User::with(['roles:id,name,display_name']);
|
||||||
->orderBy('id')
|
|
||||||
->paginate($perPage)
|
// Handle sorting
|
||||||
->withQueryString();
|
if (in_array($sortBy, ['name', 'created_at'])) {
|
||||||
|
$query->orderBy($sortBy, $sortOrder);
|
||||||
|
} else {
|
||||||
|
$query->orderBy('id', 'asc');
|
||||||
|
}
|
||||||
|
|
||||||
|
$users = $query->paginate($perPage)->withQueryString();
|
||||||
|
|
||||||
return Inertia::render('Admin/User/Index', [
|
return Inertia::render('Admin/User/Index', [
|
||||||
'users' => $users,
|
'users' => $users,
|
||||||
'filters' => $request->only(['per_page']),
|
'filters' => $request->only(['per_page', 'sort_by', 'sort_order']),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,10 +5,11 @@ namespace App\Models;
|
|||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
use Spatie\Activitylog\Traits\LogsActivity;
|
||||||
|
|
||||||
class Category extends Model
|
class Category extends Model
|
||||||
{
|
{
|
||||||
use HasFactory;
|
use HasFactory, LogsActivity;
|
||||||
|
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
'name',
|
'name',
|
||||||
@@ -27,4 +28,12 @@ class Category extends Model
|
|||||||
{
|
{
|
||||||
return $this->hasMany(Product::class);
|
return $this->hasMany(Product::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function getActivitylogOptions(): \Spatie\Activitylog\LogOptions
|
||||||
|
{
|
||||||
|
return \Spatie\Activitylog\LogOptions::defaults()
|
||||||
|
->logAll()
|
||||||
|
->logOnlyDirty()
|
||||||
|
->dontSubmitEmptyLogs();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,10 +6,13 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
|
|||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
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
|
class Product extends Model
|
||||||
{
|
{
|
||||||
use HasFactory, SoftDeletes;
|
use HasFactory, LogsActivity, SoftDeletes;
|
||||||
|
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
'code',
|
'code',
|
||||||
@@ -60,6 +63,19 @@ class Product extends Model
|
|||||||
return $this->hasMany(Inventory::class);
|
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
|
public function warehouses(): \Illuminate\Database\Eloquent\Relations\BelongsToMany
|
||||||
{
|
{
|
||||||
return $this->belongsToMany(Warehouse::class, 'inventories')
|
return $this->belongsToMany(Warehouse::class, 'inventories')
|
||||||
|
|||||||
@@ -6,10 +6,12 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
|
|||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
use Spatie\Activitylog\Traits\LogsActivity;
|
||||||
|
use Spatie\Activitylog\LogOptions;
|
||||||
|
|
||||||
class PurchaseOrder extends Model
|
class PurchaseOrder extends Model
|
||||||
{
|
{
|
||||||
use HasFactory;
|
use HasFactory, LogsActivity;
|
||||||
|
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
'code',
|
'code',
|
||||||
@@ -125,4 +127,12 @@ class PurchaseOrder extends Model
|
|||||||
{
|
{
|
||||||
return $this->hasMany(PurchaseOrderItem::class);
|
return $this->hasMany(PurchaseOrderItem::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function getActivitylogOptions(): LogOptions
|
||||||
|
{
|
||||||
|
return LogOptions::defaults()
|
||||||
|
->logAll()
|
||||||
|
->logOnlyDirty()
|
||||||
|
->dontSubmitEmptyLogs();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
20
app/Models/Role.php
Normal file
20
app/Models/Role.php
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Spatie\Permission\Models\Role as SpatieRole;
|
||||||
|
use Spatie\Activitylog\Traits\LogsActivity;
|
||||||
|
use Spatie\Activitylog\LogOptions;
|
||||||
|
|
||||||
|
class Role extends SpatieRole
|
||||||
|
{
|
||||||
|
use LogsActivity;
|
||||||
|
|
||||||
|
public function getActivitylogOptions(): LogOptions
|
||||||
|
{
|
||||||
|
return LogOptions::defaults()
|
||||||
|
->logAll()
|
||||||
|
->logOnlyDirty()
|
||||||
|
->dontSubmitEmptyLogs();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,14 +4,23 @@ namespace App\Models;
|
|||||||
|
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Spatie\Activitylog\Traits\LogsActivity;
|
||||||
|
|
||||||
class Unit extends Model
|
class Unit extends Model
|
||||||
{
|
{
|
||||||
/** @use HasFactory<\Database\Factories\UnitFactory> */
|
/** @use HasFactory<\Database\Factories\UnitFactory> */
|
||||||
use HasFactory;
|
use HasFactory, LogsActivity;
|
||||||
|
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
'name',
|
'name',
|
||||||
'code',
|
'code',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
public function getActivitylogOptions(): \Spatie\Activitylog\LogOptions
|
||||||
|
{
|
||||||
|
return \Spatie\Activitylog\LogOptions::defaults()
|
||||||
|
->logAll()
|
||||||
|
->logOnlyDirty()
|
||||||
|
->dontSubmitEmptyLogs();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,11 +7,13 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
|
|||||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||||
use Illuminate\Notifications\Notifiable;
|
use Illuminate\Notifications\Notifiable;
|
||||||
use Spatie\Permission\Traits\HasRoles;
|
use Spatie\Permission\Traits\HasRoles;
|
||||||
|
use Spatie\Activitylog\Traits\LogsActivity;
|
||||||
|
use Spatie\Activitylog\LogOptions;
|
||||||
|
|
||||||
class User extends Authenticatable
|
class User extends Authenticatable
|
||||||
{
|
{
|
||||||
/** @use HasFactory<\Database\Factories\UserFactory> */
|
/** @use HasFactory<\Database\Factories\UserFactory> */
|
||||||
use HasFactory, Notifiable, HasRoles;
|
use HasFactory, Notifiable, HasRoles, LogsActivity;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The attributes that are mass assignable.
|
* The attributes that are mass assignable.
|
||||||
@@ -47,4 +49,12 @@ class User extends Authenticatable
|
|||||||
'password' => 'hashed',
|
'password' => 'hashed',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function getActivitylogOptions(): LogOptions
|
||||||
|
{
|
||||||
|
return LogOptions::defaults()
|
||||||
|
->logAll()
|
||||||
|
->logOnlyDirty()
|
||||||
|
->dontSubmitEmptyLogs();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,9 +5,13 @@ namespace App\Models;
|
|||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
use Spatie\Activitylog\Traits\LogsActivity;
|
||||||
|
use Spatie\Activitylog\LogOptions;
|
||||||
|
|
||||||
class Vendor extends Model
|
class Vendor extends Model
|
||||||
{
|
{
|
||||||
|
use LogsActivity;
|
||||||
|
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
'code',
|
'code',
|
||||||
'name',
|
'name',
|
||||||
@@ -32,4 +36,12 @@ class Vendor extends Model
|
|||||||
{
|
{
|
||||||
return $this->hasMany(PurchaseOrder::class);
|
return $this->hasMany(PurchaseOrder::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function getActivitylogOptions(): LogOptions
|
||||||
|
{
|
||||||
|
return LogOptions::defaults()
|
||||||
|
->logAll()
|
||||||
|
->logOnlyDirty()
|
||||||
|
->dontSubmitEmptyLogs();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
"inertiajs/inertia-laravel": "^2.0",
|
"inertiajs/inertia-laravel": "^2.0",
|
||||||
"laravel/framework": "^12.0",
|
"laravel/framework": "^12.0",
|
||||||
"laravel/tinker": "^2.10.1",
|
"laravel/tinker": "^2.10.1",
|
||||||
|
"spatie/laravel-activitylog": "^4.10",
|
||||||
"spatie/laravel-permission": "^6.24",
|
"spatie/laravel-permission": "^6.24",
|
||||||
"stancl/tenancy": "^3.9",
|
"stancl/tenancy": "^3.9",
|
||||||
"tightenco/ziggy": "^2.6"
|
"tightenco/ziggy": "^2.6"
|
||||||
|
|||||||
154
composer.lock
generated
154
composer.lock
generated
@@ -4,7 +4,7 @@
|
|||||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||||
"This file is @generated automatically"
|
"This file is @generated automatically"
|
||||||
],
|
],
|
||||||
"content-hash": "931b01f076d9ee28568cd36f178a0c04",
|
"content-hash": "131ea6e8cc24a6a55229afded6bd9014",
|
||||||
"packages": [
|
"packages": [
|
||||||
{
|
{
|
||||||
"name": "brick/math",
|
"name": "brick/math",
|
||||||
@@ -3413,6 +3413,158 @@
|
|||||||
},
|
},
|
||||||
"time": "2025-12-14T04:43:48+00:00"
|
"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",
|
"name": "spatie/laravel-permission",
|
||||||
"version": "6.24.0",
|
"version": "6.24.0",
|
||||||
|
|||||||
52
config/activitylog.php
Normal file
52
config/activitylog.php
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
/*
|
||||||
|
* If set to false, no activities will be saved to the database.
|
||||||
|
*/
|
||||||
|
'enabled' => 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'),
|
||||||
|
];
|
||||||
@@ -24,7 +24,7 @@ return [
|
|||||||
* `Spatie\Permission\Contracts\Role` contract.
|
* `Spatie\Permission\Contracts\Role` contract.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
'role' => Spatie\Permission\Models\Role::class,
|
'role' => App\Models\Role::class,
|
||||||
|
|
||||||
],
|
],
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
|
||||||
|
class CreateActivityLogTable extends Migration
|
||||||
|
{
|
||||||
|
public function up()
|
||||||
|
{
|
||||||
|
Schema::connection(config('activitylog.database_connection'))->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'));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
|
||||||
|
class AddEventColumnToActivityLogTable extends Migration
|
||||||
|
{
|
||||||
|
public function up()
|
||||||
|
{
|
||||||
|
Schema::connection(config('activitylog.database_connection'))->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');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
|
||||||
|
class AddBatchUuidColumnToActivityLogTable extends Migration
|
||||||
|
{
|
||||||
|
public function up()
|
||||||
|
{
|
||||||
|
Schema::connection(config('activitylog.database_connection'))->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');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -60,6 +60,9 @@ class PermissionSeeder extends Seeder
|
|||||||
'roles.create',
|
'roles.create',
|
||||||
'roles.edit',
|
'roles.edit',
|
||||||
'roles.delete',
|
'roles.delete',
|
||||||
|
|
||||||
|
// 系統日誌
|
||||||
|
'system.view_logs',
|
||||||
];
|
];
|
||||||
|
|
||||||
foreach ($permissions as $permission) {
|
foreach ($permissions as $permission) {
|
||||||
@@ -87,6 +90,7 @@ class PermissionSeeder extends Seeder
|
|||||||
'vendors.view', 'vendors.create', 'vendors.edit', 'vendors.delete',
|
'vendors.view', 'vendors.create', 'vendors.edit', 'vendors.delete',
|
||||||
'warehouses.view', 'warehouses.create', 'warehouses.edit', 'warehouses.delete',
|
'warehouses.view', 'warehouses.create', 'warehouses.edit', 'warehouses.delete',
|
||||||
'users.view', 'users.create', 'users.edit',
|
'users.view', 'users.create', 'users.edit',
|
||||||
|
'system.view_logs',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// warehouse-manager 管理庫存與倉庫
|
// warehouse-manager 管理庫存與倉庫
|
||||||
|
|||||||
@@ -75,22 +75,22 @@ export default function ProductTable({
|
|||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead className="w-[50px] text-center">#</TableHead>
|
<TableHead className="w-[50px] text-center">#</TableHead>
|
||||||
<TableHead>
|
<TableHead>
|
||||||
<button onClick={() => onSort("code")} className="flex items-center hover:text-gray-900 font-semibold">
|
<button onClick={() => onSort("code")} className="flex items-center hover:text-gray-900">
|
||||||
商品編號 <SortIcon field="code" />
|
商品編號 <SortIcon field="code" />
|
||||||
</button>
|
</button>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead>
|
<TableHead>
|
||||||
<button onClick={() => onSort("name")} className="flex items-center hover:text-gray-900 font-semibold">
|
<button onClick={() => onSort("name")} className="flex items-center hover:text-gray-900">
|
||||||
商品名稱 <SortIcon field="name" />
|
商品名稱 <SortIcon field="name" />
|
||||||
</button>
|
</button>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead>
|
<TableHead>
|
||||||
<button onClick={() => onSort("category_id")} className="flex items-center hover:text-gray-900 font-semibold">
|
<button onClick={() => onSort("category_id")} className="flex items-center hover:text-gray-900">
|
||||||
分類 <SortIcon field="category_id" />
|
分類 <SortIcon field="category_id" />
|
||||||
</button>
|
</button>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead>
|
<TableHead>
|
||||||
<button onClick={() => onSort("base_unit_id")} className="flex items-center hover:text-gray-900 font-semibold">
|
<button onClick={() => onSort("base_unit_id")} className="flex items-center hover:text-gray-900">
|
||||||
基本單位 <SortIcon field="base_unit_id" />
|
基本單位 <SortIcon field="base_unit_id" />
|
||||||
</button>
|
</button>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
|
|||||||
@@ -123,7 +123,7 @@ export default function PurchaseOrderTable({
|
|||||||
<TableHead className="w-[180px]">
|
<TableHead className="w-[180px]">
|
||||||
<button
|
<button
|
||||||
onClick={() => handleSort("poNumber")}
|
onClick={() => handleSort("poNumber")}
|
||||||
className="flex items-center gap-2 hover:text-foreground transition-colors font-semibold"
|
className="flex items-center gap-2 hover:text-foreground transition-colors"
|
||||||
>
|
>
|
||||||
採購單編號
|
採購單編號
|
||||||
<SortIcon field="poNumber" />
|
<SortIcon field="poNumber" />
|
||||||
@@ -132,7 +132,7 @@ export default function PurchaseOrderTable({
|
|||||||
<TableHead className="w-[200px]">
|
<TableHead className="w-[200px]">
|
||||||
<button
|
<button
|
||||||
onClick={() => handleSort("warehouse_name")}
|
onClick={() => handleSort("warehouse_name")}
|
||||||
className="flex items-center gap-2 hover:text-foreground transition-colors font-semibold"
|
className="flex items-center gap-2 hover:text-foreground transition-colors"
|
||||||
>
|
>
|
||||||
預計入庫倉庫
|
預計入庫倉庫
|
||||||
<SortIcon field="warehouse_name" />
|
<SortIcon field="warehouse_name" />
|
||||||
@@ -141,7 +141,7 @@ export default function PurchaseOrderTable({
|
|||||||
<TableHead className="w-[180px]">
|
<TableHead className="w-[180px]">
|
||||||
<button
|
<button
|
||||||
onClick={() => handleSort("supplierName")}
|
onClick={() => handleSort("supplierName")}
|
||||||
className="flex items-center gap-2 hover:text-foreground transition-colors font-semibold"
|
className="flex items-center gap-2 hover:text-foreground transition-colors"
|
||||||
>
|
>
|
||||||
供應商
|
供應商
|
||||||
<SortIcon field="supplierName" />
|
<SortIcon field="supplierName" />
|
||||||
@@ -150,7 +150,7 @@ export default function PurchaseOrderTable({
|
|||||||
<TableHead className="w-[150px]">
|
<TableHead className="w-[150px]">
|
||||||
<button
|
<button
|
||||||
onClick={() => handleSort("createdAt")}
|
onClick={() => handleSort("createdAt")}
|
||||||
className="flex items-center gap-2 hover:text-foreground transition-colors font-semibold"
|
className="flex items-center gap-2 hover:text-foreground transition-colors"
|
||||||
>
|
>
|
||||||
建立日期
|
建立日期
|
||||||
<SortIcon field="createdAt" />
|
<SortIcon field="createdAt" />
|
||||||
@@ -159,7 +159,7 @@ export default function PurchaseOrderTable({
|
|||||||
<TableHead className="w-[140px] text-right">
|
<TableHead className="w-[140px] text-right">
|
||||||
<button
|
<button
|
||||||
onClick={() => handleSort("totalAmount")}
|
onClick={() => handleSort("totalAmount")}
|
||||||
className="flex items-center gap-2 ml-auto hover:text-foreground transition-colors font-semibold"
|
className="flex items-center gap-2 ml-auto hover:text-foreground transition-colors"
|
||||||
>
|
>
|
||||||
總金額
|
總金額
|
||||||
<SortIcon field="totalAmount" />
|
<SortIcon field="totalAmount" />
|
||||||
@@ -168,13 +168,13 @@ export default function PurchaseOrderTable({
|
|||||||
<TableHead className="w-[120px]">
|
<TableHead className="w-[120px]">
|
||||||
<button
|
<button
|
||||||
onClick={() => handleSort("status")}
|
onClick={() => handleSort("status")}
|
||||||
className="flex items-center gap-2 hover:text-foreground transition-colors font-semibold"
|
className="flex items-center gap-2 hover:text-foreground transition-colors"
|
||||||
>
|
>
|
||||||
狀態
|
狀態
|
||||||
<SortIcon field="status" />
|
<SortIcon field="status" />
|
||||||
</button>
|
</button>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead className="text-center font-semibold">操作</TableHead>
|
<TableHead className="text-center">操作</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
|
|||||||
@@ -27,14 +27,14 @@ export default function SupplyProductList({
|
|||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead className="w-[50px] text-center">#</TableHead>
|
<TableHead className="w-[50px] text-center">#</TableHead>
|
||||||
<TableHead className="font-semibold">商品名稱</TableHead>
|
<TableHead>商品名稱</TableHead>
|
||||||
<TableHead className="font-semibold">基本單位</TableHead>
|
<TableHead>基本單位</TableHead>
|
||||||
<TableHead className="font-semibold">轉換率</TableHead>
|
<TableHead>轉換率</TableHead>
|
||||||
<TableHead className="text-right font-semibold">
|
<TableHead className="text-right">
|
||||||
上次採購單價
|
上次採購單價
|
||||||
<div className="text-xs font-normal text-muted-foreground">(以基本單位計算)</div>
|
<div className="text-xs font-normal text-muted-foreground">(以基本單位計算)</div>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead className="text-center font-semibold w-[150px]">操作</TableHead>
|
<TableHead className="text-center w-[150px]">操作</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
|
|||||||
10
resources/js/Components/Vendor/VendorTable.tsx
vendored
10
resources/js/Components/Vendor/VendorTable.tsx
vendored
@@ -61,27 +61,27 @@ export default function VendorTable({
|
|||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead className="w-[50px] text-center">#</TableHead>
|
<TableHead className="w-[50px] text-center">#</TableHead>
|
||||||
<TableHead>
|
<TableHead>
|
||||||
<button onClick={() => onSort("code")} className="flex items-center hover:text-gray-900 font-semibold">
|
<button onClick={() => onSort("code")} className="flex items-center hover:text-gray-900">
|
||||||
編號 <SortIcon field="code" />
|
編號 <SortIcon field="code" />
|
||||||
</button>
|
</button>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead>
|
<TableHead>
|
||||||
<button onClick={() => onSort("name")} className="flex items-center hover:text-gray-900 font-semibold">
|
<button onClick={() => onSort("name")} className="flex items-center hover:text-gray-900">
|
||||||
廠商名稱 <SortIcon field="name" />
|
廠商名稱 <SortIcon field="name" />
|
||||||
</button>
|
</button>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead>
|
<TableHead>
|
||||||
<button onClick={() => onSort("owner")} className="flex items-center hover:text-gray-900 font-semibold">
|
<button onClick={() => onSort("owner")} className="flex items-center hover:text-gray-900">
|
||||||
負責人 <SortIcon field="owner" />
|
負責人 <SortIcon field="owner" />
|
||||||
</button>
|
</button>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead>
|
<TableHead>
|
||||||
<button onClick={() => onSort("contact_name")} className="flex items-center hover:text-gray-900 font-semibold">
|
<button onClick={() => onSort("contact_name")} className="flex items-center hover:text-gray-900">
|
||||||
聯絡人 <SortIcon field="contact_name" />
|
聯絡人 <SortIcon field="contact_name" />
|
||||||
</button>
|
</button>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead>
|
<TableHead>
|
||||||
<button onClick={() => onSort("phone")} className="flex items-center hover:text-gray-900 font-semibold">
|
<button onClick={() => onSort("phone")} className="flex items-center hover:text-gray-900">
|
||||||
聯絡電話 <SortIcon field="phone" />
|
聯絡電話 <SortIcon field="phone" />
|
||||||
</button>
|
</button>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
|
|||||||
@@ -183,37 +183,37 @@ export default function InventoryTable({
|
|||||||
<TableRow className="bg-gray-50/50">
|
<TableRow className="bg-gray-50/50">
|
||||||
<TableHead className="w-[50px] text-center">#</TableHead>
|
<TableHead className="w-[50px] text-center">#</TableHead>
|
||||||
<TableHead className="w-[25%]">
|
<TableHead className="w-[25%]">
|
||||||
<button onClick={() => handleSort("productName")} className="flex items-center hover:text-gray-900 font-semibold">
|
<button onClick={() => handleSort("productName")} className="flex items-center hover:text-gray-900">
|
||||||
商品資訊 <SortIcon field="productName" />
|
商品資訊 <SortIcon field="productName" />
|
||||||
</button>
|
</button>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead className="w-[10%] text-right">
|
<TableHead className="w-[10%] text-right">
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
<button onClick={() => handleSort("quantity")} className="flex items-center hover:text-gray-900 font-semibold">
|
<button onClick={() => handleSort("quantity")} className="flex items-center hover:text-gray-900">
|
||||||
庫存數量 <SortIcon field="quantity" />
|
庫存數量 <SortIcon field="quantity" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead className="w-[12%]">
|
<TableHead className="w-[12%]">
|
||||||
<button onClick={() => handleSort("lastInboundDate")} className="flex items-center hover:text-gray-900 font-semibold">
|
<button onClick={() => handleSort("lastInboundDate")} className="flex items-center hover:text-gray-900">
|
||||||
最新入庫 <SortIcon field="lastInboundDate" />
|
最新入庫 <SortIcon field="lastInboundDate" />
|
||||||
</button>
|
</button>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead className="w-[12%]">
|
<TableHead className="w-[12%]">
|
||||||
<button onClick={() => handleSort("lastOutboundDate")} className="flex items-center hover:text-gray-900 font-semibold">
|
<button onClick={() => handleSort("lastOutboundDate")} className="flex items-center hover:text-gray-900">
|
||||||
最新出庫 <SortIcon field="lastOutboundDate" />
|
最新出庫 <SortIcon field="lastOutboundDate" />
|
||||||
</button>
|
</button>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead className="w-[10%] text-right">
|
<TableHead className="w-[10%] text-right">
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
<button onClick={() => handleSort("safetyStock")} className="flex items-center hover:text-gray-900 font-semibold">
|
<button onClick={() => handleSort("safetyStock")} className="flex items-center hover:text-gray-900">
|
||||||
安全庫存 <SortIcon field="safetyStock" />
|
安全庫存 <SortIcon field="safetyStock" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead className="w-[10%] text-center">
|
<TableHead className="w-[10%] text-center">
|
||||||
<div className="flex justify-center">
|
<div className="flex justify-center">
|
||||||
<button onClick={() => handleSort("status")} className="flex items-center hover:text-gray-900 font-semibold">
|
<button onClick={() => handleSort("status")} className="flex items-center hover:text-gray-900">
|
||||||
狀態 <SortIcon field="status" />
|
狀態 <SortIcon field="status" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -16,7 +16,8 @@ import {
|
|||||||
ChevronDown,
|
ChevronDown,
|
||||||
Settings,
|
Settings,
|
||||||
Shield,
|
Shield,
|
||||||
Users
|
Users,
|
||||||
|
FileText
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { toast, Toaster } from "sonner";
|
import { toast, Toaster } from "sonner";
|
||||||
import { useState, useEffect, useMemo } from "react";
|
import { useState, useEffect, useMemo } from "react";
|
||||||
@@ -145,6 +146,13 @@ export default function AuthenticatedLayout({
|
|||||||
route: "/admin/roles",
|
route: "/admin/roles",
|
||||||
permission: "roles.view",
|
permission: "roles.view",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "activity-log",
|
||||||
|
label: "操作紀錄",
|
||||||
|
icon: <FileText className="h-4 w-4" />,
|
||||||
|
route: "/admin/activity-logs",
|
||||||
|
permission: "system.view_logs",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
142
resources/js/Pages/Admin/ActivityLog/ActivityDetailDialog.tsx
Normal file
142
resources/js/Pages/Admin/ActivityLog/ActivityDetailDialog.tsx
Normal file
@@ -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<string, any>;
|
||||||
|
old?: Record<string, any>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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 <span className="text-gray-400">-</span>;
|
||||||
|
if (typeof value === 'boolean') return value ? '是' : '否';
|
||||||
|
if (typeof value === 'object') return JSON.stringify(value);
|
||||||
|
return String(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="max-w-2xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
操作詳情
|
||||||
|
<Badge className={getEventBadgeColor(activity.event)}>
|
||||||
|
{getEventLabel(activity.event)}
|
||||||
|
</Badge>
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{activity.created_at} 由 {activity.causer} 執行的操作
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="mt-4 space-y-4">
|
||||||
|
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-500">操作對象:</span>
|
||||||
|
<span className="font-medium ml-2">{activity.subject_type}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-500">描述:</span>
|
||||||
|
<span className="font-medium ml-2">{activity.description}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{activity.event === 'created' ? (
|
||||||
|
<div className="bg-gray-50 p-4 rounded-md text-center text-gray-500 text-sm">
|
||||||
|
已新增資料 (初始建立)
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="border rounded-md">
|
||||||
|
<div className="grid grid-cols-3 bg-gray-50 p-2 text-sm font-medium text-gray-500">
|
||||||
|
<div>欄位</div>
|
||||||
|
<div>異動前</div>
|
||||||
|
<div>異動後</div>
|
||||||
|
</div>
|
||||||
|
<ScrollArea className="h-[300px]">
|
||||||
|
{filteredKeys.length > 0 ? (
|
||||||
|
<div className="divide-y">
|
||||||
|
{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 (
|
||||||
|
<div key={key} className={`grid grid-cols-3 p-2 text-sm ${isChanged ? 'bg-yellow-50/30' : ''}`}>
|
||||||
|
<div className="font-medium text-gray-700">{key}</div>
|
||||||
|
<div className="text-gray-600 break-words pr-2">
|
||||||
|
{formatValue(oldValue)}
|
||||||
|
</div>
|
||||||
|
<div className="text-gray-900 break-words font-medium">
|
||||||
|
{activity.event === 'deleted' ? '-' : formatValue(newValue)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="p-8 text-center text-gray-500 text-sm">
|
||||||
|
無詳細異動內容
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ScrollArea>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
247
resources/js/Pages/Admin/ActivityLog/Index.tsx
Normal file
247
resources/js/Pages/Admin/ActivityLog/Index.tsx
Normal file
@@ -0,0 +1,247 @@
|
|||||||
|
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, ArrowUpDown, ArrowUp, ArrowDown } 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;
|
||||||
|
sort_by?: string;
|
||||||
|
sort_order?: 'asc' | 'desc';
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ActivityLogIndex({ activities, filters }: Props) {
|
||||||
|
const [perPage, setPerPage] = useState<string>(filters.per_page || "10");
|
||||||
|
const [selectedActivity, setSelectedActivity] = useState<Activity | null>(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'),
|
||||||
|
{ ...filters, per_page: value },
|
||||||
|
{ preserveState: false, replace: true, preserveScroll: true }
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSort = (field: string) => {
|
||||||
|
let newSortBy: string | undefined = field;
|
||||||
|
let newSortOrder: 'asc' | 'desc' | undefined = 'asc';
|
||||||
|
|
||||||
|
if (filters.sort_by === field) {
|
||||||
|
if (filters.sort_order === 'asc') {
|
||||||
|
newSortOrder = 'desc';
|
||||||
|
} else {
|
||||||
|
newSortBy = undefined;
|
||||||
|
newSortOrder = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
route('activity-logs.index'),
|
||||||
|
{ ...filters, sort_by: newSortBy, sort_order: newSortOrder },
|
||||||
|
{ preserveState: true, replace: true }
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const SortIcon = ({ field }: { field: string }) => {
|
||||||
|
if (filters.sort_by !== field) {
|
||||||
|
return <ArrowUpDown className="h-4 w-4 text-muted-foreground ml-1" />;
|
||||||
|
}
|
||||||
|
if (filters.sort_order === "asc") {
|
||||||
|
return <ArrowUp className="h-4 w-4 text-primary-main ml-1" />;
|
||||||
|
}
|
||||||
|
return <ArrowDown className="h-4 w-4 text-primary-main ml-1" />;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthenticatedLayout
|
||||||
|
breadcrumbs={[
|
||||||
|
{ label: '系統管理', href: '#' },
|
||||||
|
{ label: '操作紀錄', href: route('activity-logs.index'), isPage: true },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Head title="操作紀錄" />
|
||||||
|
|
||||||
|
<div className="container mx-auto p-6 max-w-7xl">
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-grey-0 flex items-center gap-2">
|
||||||
|
<FileText className="h-6 w-6 text-primary-main" />
|
||||||
|
操作紀錄
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-500 mt-1">
|
||||||
|
檢視系統內的所有操作活動,包含新增、修改與刪除紀錄
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden">
|
||||||
|
<Table>
|
||||||
|
<TableHeader className="bg-gray-50">
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="w-[50px] text-center">#</TableHead>
|
||||||
|
<TableHead className="w-[180px]">
|
||||||
|
<button
|
||||||
|
onClick={() => handleSort('created_at')}
|
||||||
|
className="flex items-center gap-1 hover:text-gray-900 transition-colors"
|
||||||
|
>
|
||||||
|
時間 <SortIcon field="created_at" />
|
||||||
|
</button>
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="w-[150px]">操作人員</TableHead>
|
||||||
|
<TableHead className="w-[100px] text-center">動作</TableHead>
|
||||||
|
<TableHead className="w-[150px]">對象</TableHead>
|
||||||
|
<TableHead>描述</TableHead>
|
||||||
|
<TableHead className="w-[100px] text-center">操作</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{activities.data.length > 0 ? (
|
||||||
|
activities.data.map((activity, index) => (
|
||||||
|
<TableRow key={activity.id}>
|
||||||
|
<TableCell className="text-gray-500 font-medium text-center">
|
||||||
|
{activities.from + index}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-gray-500 font-medium whitespace-nowrap">
|
||||||
|
{activity.created_at}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<span className="font-medium text-gray-900">{activity.causer}</span>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-center">
|
||||||
|
<Badge className={getEventBadgeColor(activity.event)}>
|
||||||
|
{getEventLabel(activity.event)}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant="outline" className="bg-slate-50">
|
||||||
|
{activity.subject_type}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-gray-600" title={activity.description}>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span>{activity.causer}</span>
|
||||||
|
<span className="text-gray-400">執行了</span>
|
||||||
|
<span className="font-medium text-gray-700">{activity.description}</span>
|
||||||
|
<span className="text-gray-400">動作</span>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-center">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleViewDetail(activity)}
|
||||||
|
className="button-outlined-primary"
|
||||||
|
title="檢視詳情"
|
||||||
|
>
|
||||||
|
<Eye className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={7} className="text-center py-8 text-gray-500">
|
||||||
|
尚無操作紀錄
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 flex flex-col sm:flex-row items-center justify-between gap-4">
|
||||||
|
<div className="flex items-center gap-2 text-sm text-gray-500">
|
||||||
|
<span>每頁顯示</span>
|
||||||
|
<SearchableSelect
|
||||||
|
value={perPage}
|
||||||
|
onValueChange={handlePerPageChange}
|
||||||
|
options={[
|
||||||
|
{ label: "10", value: "10" },
|
||||||
|
{ label: "20", value: "20" },
|
||||||
|
{ label: "50", value: "50" },
|
||||||
|
{ label: "100", value: "100" }
|
||||||
|
]}
|
||||||
|
className="w-[80px] h-8"
|
||||||
|
showSearch={false}
|
||||||
|
/>
|
||||||
|
<span>筆</span>
|
||||||
|
</div>
|
||||||
|
<Pagination links={activities.links} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ActivityDetailDialog
|
||||||
|
open={detailOpen}
|
||||||
|
onOpenChange={setDetailOpen}
|
||||||
|
activity={selectedActivity}
|
||||||
|
/>
|
||||||
|
</AuthenticatedLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout';
|
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout';
|
||||||
import { Head, Link, router } from '@inertiajs/react';
|
import { Head, Link, router } from '@inertiajs/react';
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { Shield, Plus, Pencil, Trash2, Users } from 'lucide-react';
|
import { Shield, Plus, Pencil, Trash2, Users, ArrowUpDown, ArrowUp, ArrowDown } from 'lucide-react';
|
||||||
import { Button } from '@/Components/ui/button';
|
import { Button } from '@/Components/ui/button';
|
||||||
import { Badge } from '@/Components/ui/badge';
|
import { Badge } from '@/Components/ui/badge';
|
||||||
import {
|
import {
|
||||||
@@ -42,9 +42,13 @@ interface Role {
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
roles: Role[];
|
roles: Role[];
|
||||||
|
filters: {
|
||||||
|
sort_by?: string;
|
||||||
|
sort_order?: 'asc' | 'desc';
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function RoleIndex({ roles }: Props) {
|
export default function RoleIndex({ roles, filters = {} }: Props) {
|
||||||
const [selectedRole, setSelectedRole] = useState<Role | null>(null);
|
const [selectedRole, setSelectedRole] = useState<Role | null>(null);
|
||||||
|
|
||||||
const handleDelete = (id: number, name: string) => {
|
const handleDelete = (id: number, name: string) => {
|
||||||
@@ -55,6 +59,36 @@ export default function RoleIndex({ roles }: Props) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleSort = (field: string) => {
|
||||||
|
let newSortBy: string | undefined = field;
|
||||||
|
let newSortOrder: 'asc' | 'desc' | undefined = 'asc';
|
||||||
|
|
||||||
|
if (filters.sort_by === field) {
|
||||||
|
if (filters.sort_order === 'asc') {
|
||||||
|
newSortOrder = 'desc';
|
||||||
|
} else {
|
||||||
|
newSortBy = undefined;
|
||||||
|
newSortOrder = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
route('roles.index'),
|
||||||
|
{ ...filters, sort_by: newSortBy, sort_order: newSortOrder },
|
||||||
|
{ preserveState: true, replace: true }
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const SortIcon = ({ field }: { field: string }) => {
|
||||||
|
if (filters.sort_by !== field) {
|
||||||
|
return <ArrowUpDown className="h-4 w-4 text-muted-foreground ml-1" />;
|
||||||
|
}
|
||||||
|
if (filters.sort_order === "asc") {
|
||||||
|
return <ArrowUp className="h-4 w-4 text-primary-main ml-1" />;
|
||||||
|
}
|
||||||
|
return <ArrowDown className="h-4 w-4 text-primary-main ml-1" />;
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthenticatedLayout
|
<AuthenticatedLayout
|
||||||
breadcrumbs={[
|
breadcrumbs={[
|
||||||
@@ -92,9 +126,30 @@ export default function RoleIndex({ roles }: Props) {
|
|||||||
<TableHead className="w-[50px] text-center">#</TableHead>
|
<TableHead className="w-[50px] text-center">#</TableHead>
|
||||||
<TableHead>名稱</TableHead>
|
<TableHead>名稱</TableHead>
|
||||||
<TableHead>代號</TableHead>
|
<TableHead>代號</TableHead>
|
||||||
<TableHead className="text-center">權限數量</TableHead>
|
<TableHead className="text-center">
|
||||||
<TableHead className="text-center">使用者人數</TableHead>
|
<button
|
||||||
<TableHead className="text-left">建立時間</TableHead>
|
onClick={() => handleSort('permissions_count')}
|
||||||
|
className="flex items-center justify-center gap-1 w-full hover:text-gray-900 transition-colors"
|
||||||
|
>
|
||||||
|
權限數量 <SortIcon field="permissions_count" />
|
||||||
|
</button>
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="text-center">
|
||||||
|
<button
|
||||||
|
onClick={() => handleSort('users_count')}
|
||||||
|
className="flex items-center justify-center gap-1 w-full hover:text-gray-900 transition-colors"
|
||||||
|
>
|
||||||
|
使用者人數 <SortIcon field="users_count" />
|
||||||
|
</button>
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="text-left">
|
||||||
|
<button
|
||||||
|
onClick={() => handleSort('created_at')}
|
||||||
|
className="flex items-center gap-1 hover:text-gray-900 transition-colors"
|
||||||
|
>
|
||||||
|
建立時間 <SortIcon field="created_at" />
|
||||||
|
</button>
|
||||||
|
</TableHead>
|
||||||
<TableHead className="text-center">操作</TableHead>
|
<TableHead className="text-center">操作</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout';
|
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout';
|
||||||
import { Head, Link, router } from '@inertiajs/react';
|
import { Head, Link, router } from '@inertiajs/react';
|
||||||
import { Users, Plus, Pencil, Trash2, Mail, Shield } from 'lucide-react';
|
import { Users, Plus, Pencil, Trash2, Mail, Shield, ArrowUpDown, ArrowUp, ArrowDown } from 'lucide-react';
|
||||||
import { Button } from '@/Components/ui/button';
|
import { Button } from '@/Components/ui/button';
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
@@ -47,6 +47,8 @@ interface Props {
|
|||||||
};
|
};
|
||||||
filters: {
|
filters: {
|
||||||
per_page?: string;
|
per_page?: string;
|
||||||
|
sort_by?: string;
|
||||||
|
sort_order?: 'asc' | 'desc';
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,11 +68,41 @@ export default function UserIndex({ users, filters }: Props) {
|
|||||||
setPerPage(value);
|
setPerPage(value);
|
||||||
router.get(
|
router.get(
|
||||||
route('users.index'),
|
route('users.index'),
|
||||||
{ per_page: value },
|
{ ...filters, per_page: value },
|
||||||
{ preserveState: false, replace: true, preserveScroll: true }
|
{ preserveState: false, replace: true, preserveScroll: true }
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleSort = (field: string) => {
|
||||||
|
let newSortBy: string | undefined = field;
|
||||||
|
let newSortOrder: 'asc' | 'desc' | undefined = 'asc';
|
||||||
|
|
||||||
|
if (filters.sort_by === field) {
|
||||||
|
if (filters.sort_order === 'asc') {
|
||||||
|
newSortOrder = 'desc';
|
||||||
|
} else {
|
||||||
|
newSortBy = undefined;
|
||||||
|
newSortOrder = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
route('users.index'),
|
||||||
|
{ ...filters, sort_by: newSortBy, sort_order: newSortOrder },
|
||||||
|
{ preserveState: true, replace: true }
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const SortIcon = ({ field }: { field: string }) => {
|
||||||
|
if (filters.sort_by !== field) {
|
||||||
|
return <ArrowUpDown className="h-4 w-4 text-muted-foreground ml-1" />;
|
||||||
|
}
|
||||||
|
if (filters.sort_order === "asc") {
|
||||||
|
return <ArrowUp className="h-4 w-4 text-primary-main ml-1" />;
|
||||||
|
}
|
||||||
|
return <ArrowDown className="h-4 w-4 text-primary-main ml-1" />;
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -108,9 +140,23 @@ export default function UserIndex({ users, filters }: Props) {
|
|||||||
<TableHeader className="bg-gray-50">
|
<TableHeader className="bg-gray-50">
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead className="w-[50px] text-center">#</TableHead>
|
<TableHead className="w-[50px] text-center">#</TableHead>
|
||||||
<TableHead className="w-[250px]">使用者</TableHead>
|
<TableHead>
|
||||||
|
<button
|
||||||
|
onClick={() => handleSort('name')}
|
||||||
|
className="flex items-center gap-1 hover:text-gray-900 transition-colors"
|
||||||
|
>
|
||||||
|
使用者 <SortIcon field="name" />
|
||||||
|
</button>
|
||||||
|
</TableHead>
|
||||||
<TableHead>角色</TableHead>
|
<TableHead>角色</TableHead>
|
||||||
<TableHead className="w-[200px]">加入時間</TableHead>
|
<TableHead>
|
||||||
|
<button
|
||||||
|
onClick={() => handleSort('created_at')}
|
||||||
|
className="flex items-center gap-1 hover:text-gray-900 transition-colors"
|
||||||
|
>
|
||||||
|
加入時間 <SortIcon field="created_at" />
|
||||||
|
</button>
|
||||||
|
</TableHead>
|
||||||
<TableHead className="text-center">操作</TableHead>
|
<TableHead className="text-center">操作</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ use App\Http\Controllers\TransferOrderController;
|
|||||||
use App\Http\Controllers\UnitController;
|
use App\Http\Controllers\UnitController;
|
||||||
use App\Http\Controllers\Admin\RoleController;
|
use App\Http\Controllers\Admin\RoleController;
|
||||||
use App\Http\Controllers\Admin\UserController;
|
use App\Http\Controllers\Admin\UserController;
|
||||||
|
use App\Http\Controllers\Admin\ActivityLogController;
|
||||||
use App\Http\Controllers\ProfileController;
|
use App\Http\Controllers\ProfileController;
|
||||||
use Stancl\Tenancy\Middleware\InitializeTenancyByDomainOrSubdomain;
|
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::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::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::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
|
}); // End of auth middleware group
|
||||||
|
|||||||
Reference in New Issue
Block a user