From 18edb3cb6902c69df12d93bfc62daefaf0b51f41 Mon Sep 17 00:00:00 2001 From: sky121113 Date: Mon, 19 Jan 2026 11:47:10 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=84=AA=E5=8C=96=E6=93=8D=E4=BD=9C?= =?UTF-8?q?=E7=B4=80=E9=8C=84=E9=A1=AF=E7=A4=BA=E8=88=87=E9=82=8F=E8=BC=AF?= =?UTF-8?q?=20(=E6=81=A2=E5=BE=A9=E6=8F=8F=E8=BF=B0=E6=AC=84=E4=BD=8D?= =?UTF-8?q?=E3=80=81=E6=94=AF=E6=8F=B4=E4=BE=86=E6=BA=90=E6=A8=99=E8=A8=98?= =?UTF-8?q?=E3=80=81=E6=94=B9=E9=80=B2=E5=BF=AB=E7=85=A7)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Admin/ActivityLogController.php | 2 + app/Http/Controllers/InventoryController.php | 8 +- .../Controllers/TransferOrderController.php | 6 + app/Models/Category.php | 9 + app/Models/Inventory.php | 35 ++++ app/Models/Product.php | 28 +++ app/Models/Unit.php | 9 + app/Models/Warehouse.php | 21 ++ .../ActivityLog/ActivityDetailDialog.tsx | 197 +++++++++++++----- .../js/Pages/Admin/ActivityLog/Index.tsx | 92 ++++++-- 10 files changed, 333 insertions(+), 74 deletions(-) diff --git a/app/Http/Controllers/Admin/ActivityLogController.php b/app/Http/Controllers/Admin/ActivityLogController.php index 340fe27..d34fbfe 100644 --- a/app/Http/Controllers/Admin/ActivityLogController.php +++ b/app/Http/Controllers/Admin/ActivityLogController.php @@ -33,6 +33,8 @@ class ActivityLogController extends Controller 'App\Models\Category' => '商品分類', 'App\Models\Unit' => '單位', 'App\Models\PurchaseOrder' => '採購單', + 'App\Models\Warehouse' => '倉庫', + 'App\Models\Inventory' => '庫存', ]; $eventMap = [ diff --git a/app/Http/Controllers/InventoryController.php b/app/Http/Controllers/InventoryController.php index 4925c34..4911336 100644 --- a/app/Http/Controllers/InventoryController.php +++ b/app/Http/Controllers/InventoryController.php @@ -103,7 +103,8 @@ class InventoryController extends Controller return \Illuminate\Support\Facades\DB::transaction(function () use ($validated, $warehouse) { foreach ($validated['items'] as $item) { // 取得或建立庫存紀錄 - $inventory = $warehouse->inventories()->firstOrCreate( + // 取得或初始化庫存紀錄 + $inventory = $warehouse->inventories()->firstOrNew( ['product_id' => $item['productId']], ['quantity' => 0, 'safety_stock' => null] ); @@ -111,8 +112,9 @@ class InventoryController extends Controller $currentQty = $inventory->quantity; $newQty = $currentQty + $item['quantity']; - // 更新庫存 - $inventory->update(['quantity' => $newQty]); + // 更新庫存並儲存 (新紀錄: Created, 舊紀錄: Updated) + $inventory->quantity = $newQty; + $inventory->save(); // 寫入異動紀錄 $inventory->transactions()->create([ diff --git a/app/Http/Controllers/TransferOrderController.php b/app/Http/Controllers/TransferOrderController.php index 5516f2d..c6b7e8e 100644 --- a/app/Http/Controllers/TransferOrderController.php +++ b/app/Http/Controllers/TransferOrderController.php @@ -56,6 +56,9 @@ class TransferOrderController extends Controller // 3. 執行庫存轉移 (扣除來源) $oldSourceQty = $sourceInventory->quantity; $newSourceQty = $oldSourceQty - $validated['quantity']; + + // 設定活動紀錄原因 + $sourceInventory->activityLogReason = "撥補出庫 至 {$targetWarehouse->name}"; $sourceInventory->update(['quantity' => $newSourceQty]); // 記錄來源異動 @@ -72,6 +75,9 @@ class TransferOrderController extends Controller // 4. 執行庫存轉移 (增加目標) $oldTargetQty = $targetInventory->quantity; $newTargetQty = $oldTargetQty + $validated['quantity']; + + // 設定活動紀錄原因 + $targetInventory->activityLogReason = "撥補入庫 來自 {$sourceWarehouse->name}"; $targetInventory->update(['quantity' => $newTargetQty]); // 記錄目標異動 diff --git a/app/Models/Category.php b/app/Models/Category.php index 4e1f2e2..b541701 100644 --- a/app/Models/Category.php +++ b/app/Models/Category.php @@ -36,4 +36,13 @@ class Category extends Model ->logOnlyDirty() ->dontSubmitEmptyLogs(); } + + public function tapActivity(\Spatie\Activitylog\Contracts\Activity $activity, string $eventName) + { + $properties = $activity->properties; + $attributes = $properties['attributes'] ?? []; + $attributes['name'] = $this->name; + $properties['attributes'] = $attributes; + $activity->properties = $properties; + } } diff --git a/app/Models/Inventory.php b/app/Models/Inventory.php index 026d571..57661cb 100644 --- a/app/Models/Inventory.php +++ b/app/Models/Inventory.php @@ -9,6 +9,7 @@ class Inventory extends Model { /** @use HasFactory<\Database\Factories\InventoryFactory> */ use HasFactory; + use \Spatie\Activitylog\Traits\LogsActivity; protected $fillable = [ 'warehouse_id', @@ -18,6 +19,40 @@ class Inventory extends Model 'location', ]; + /** + * Transient property to store the reason for the activity log (e.g., "Replenishment #123"). + * This is not stored in the database column but used for logging context. + * @var string|null + */ + public $activityLogReason; + + public function getActivitylogOptions(): \Spatie\Activitylog\LogOptions + { + return \Spatie\Activitylog\LogOptions::defaults() + ->logAll() + ->logOnlyDirty() + ->dontSubmitEmptyLogs(); + } + + public function tapActivity(\Spatie\Activitylog\Contracts\Activity $activity, string $eventName) + { + $properties = $activity->properties; + $attributes = $properties['attributes'] ?? []; + + // Always snapshot names for context, even if IDs didn't change + // $this refers to the Inventory model instance + $attributes['warehouse_name'] = $this->warehouse ? $this->warehouse->name : ($attributes['warehouse_name'] ?? null); + $attributes['product_name'] = $this->product ? $this->product->name : ($attributes['product_name'] ?? null); + + // Capture the reason if set + if ($this->activityLogReason) { + $attributes['_reason'] = $this->activityLogReason; + } + + $properties['attributes'] = $attributes; + $activity->properties = $properties; + } + public function warehouse(): \Illuminate\Database\Eloquent\Relations\BelongsTo { return $this->belongsTo(Warehouse::class); diff --git a/app/Models/Product.php b/app/Models/Product.php index ef269c7..62740c3 100644 --- a/app/Models/Product.php +++ b/app/Models/Product.php @@ -76,6 +76,34 @@ class Product extends Model ->dontSubmitEmptyLogs(); } + public function tapActivity(\Spatie\Activitylog\Contracts\Activity $activity, string $eventName) + { + $properties = $activity->properties; + $attributes = $properties['attributes'] ?? []; + + // Handle Category Name Snapshot + if (isset($attributes['category_id'])) { + $category = Category::find($attributes['category_id']); + $attributes['category_name'] = $category ? $category->name : null; + } + + // Handle Unit Name Snapshots + $unitFields = ['base_unit_id', 'large_unit_id', 'purchase_unit_id']; + foreach ($unitFields as $field) { + if (isset($attributes[$field])) { + $unit = Unit::find($attributes[$field]); + $nameKey = str_replace('_id', '_name', $field); + $attributes[$nameKey] = $unit ? $unit->name : null; + } + } + + // Always snapshot self name for context (so logs always show "Cola") + $attributes['name'] = $this->name; + + $properties['attributes'] = $attributes; + $activity->properties = $properties; + } + public function warehouses(): \Illuminate\Database\Eloquent\Relations\BelongsToMany { return $this->belongsToMany(Warehouse::class, 'inventories') diff --git a/app/Models/Unit.php b/app/Models/Unit.php index 00a1cc5..a07e93d 100644 --- a/app/Models/Unit.php +++ b/app/Models/Unit.php @@ -23,4 +23,13 @@ class Unit extends Model ->logOnlyDirty() ->dontSubmitEmptyLogs(); } + + public function tapActivity(\Spatie\Activitylog\Contracts\Activity $activity, string $eventName) + { + $properties = $activity->properties; + $attributes = $properties['attributes'] ?? []; + $attributes['name'] = $this->name; + $properties['attributes'] = $attributes; + $activity->properties = $properties; + } } diff --git a/app/Models/Warehouse.php b/app/Models/Warehouse.php index 8d7b90a..eec28fc 100644 --- a/app/Models/Warehouse.php +++ b/app/Models/Warehouse.php @@ -9,6 +9,7 @@ class Warehouse extends Model { /** @use HasFactory<\Database\Factories\WarehouseFactory> */ use HasFactory; + use \Spatie\Activitylog\Traits\LogsActivity; protected $fillable = [ 'code', @@ -17,6 +18,26 @@ class Warehouse extends Model 'description', ]; + public function getActivitylogOptions(): \Spatie\Activitylog\LogOptions + { + return \Spatie\Activitylog\LogOptions::defaults() + ->logAll() + ->logOnlyDirty() + ->dontSubmitEmptyLogs(); + } + + public function tapActivity(\Spatie\Activitylog\Contracts\Activity $activity, string $eventName) + { + $properties = $activity->properties; + $attributes = $properties['attributes'] ?? []; + + // Always snapshot name + $attributes['name'] = $this->name; + + $properties['attributes'] = $attributes; + $activity->properties = $properties; + } + public function inventories(): \Illuminate\Database\Eloquent\Relations\HasMany { return $this->hasMany(Inventory::class); diff --git a/resources/js/Pages/Admin/ActivityLog/ActivityDetailDialog.tsx b/resources/js/Pages/Admin/ActivityLog/ActivityDetailDialog.tsx index f5808a9..bd65ae6 100644 --- a/resources/js/Pages/Admin/ActivityLog/ActivityDetailDialog.tsx +++ b/resources/js/Pages/Admin/ActivityLog/ActivityDetailDialog.tsx @@ -1,12 +1,12 @@ import { Dialog, DialogContent, - DialogDescription, DialogHeader, DialogTitle, } from "@/Components/ui/dialog"; import { Badge } from "@/Components/ui/badge"; import { ScrollArea } from "@/Components/ui/scroll-area"; +import { User, Clock, Package, Activity as ActivityIcon } from "lucide-react"; interface Activity { id: number; @@ -27,6 +27,43 @@ interface Props { activity: Activity | null; } +// Field translation map +const fieldLabels: Record = { + name: '名稱', + code: '代碼', + description: '描述', + price: '價格', + cost: '成本', + stock: '庫存', + category_id: '分類', + unit_id: '單位', + is_active: '啟用狀態', + conversion_rate: '換算率', + specification: '規格', + brand: '品牌', + base_unit_id: '基本單位', + large_unit_id: '大單位', + purchase_unit_id: '採購單位', + email: 'Email', + password: '密碼', + phone: '電話', + address: '地址', + role_id: '角色', + // Snapshot fields + category_name: '分類名稱', + base_unit_name: '基本單位名稱', + large_unit_name: '大單位名稱', + purchase_unit_name: '採購單位名稱', + // Warehouse & Inventory fields + warehouse_name: '倉庫名稱', + product_name: '商品名稱', + warehouse_id: '倉庫', + product_id: '商品', + quantity: '數量', + safety_stock: '安全庫存', + location: '儲位', +}; + export default function ActivityDetailDialog({ open, onOpenChange, activity }: Props) { if (!activity) return null; @@ -41,12 +78,12 @@ export default function ActivityDetailDialog({ open, onOpenChange, activity }: P !['created_at', 'updated_at', 'deleted_at', 'id'].includes(key) ); - const getEventBadgeColor = (event: string) => { + const getEventBadgeClass = (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'; + case 'created': return 'bg-green-100 text-green-700 hover:bg-green-200 border-green-200'; + case 'updated': return 'bg-blue-100 text-blue-700 hover:bg-blue-200 border-blue-200'; + case 'deleted': return 'bg-red-100 text-red-700 hover:bg-red-200 border-red-200'; + default: return 'bg-gray-100 text-gray-700 hover:bg-gray-200 border-gray-200'; } }; @@ -59,73 +96,133 @@ export default function ActivityDetailDialog({ open, onOpenChange, activity }: P } }; - const formatValue = (value: any) => { + const formatValue = (key: string, value: any) => { if (value === null || value === undefined) return -; - if (typeof value === 'boolean') return value ? '是' : '否'; + + // Special handling for boolean values based on key + if (typeof value === 'boolean') { + if (key === 'is_active') return value ? '啟用' : '停用'; + return value ? '是' : '否'; + } + if (typeof value === 'object') return JSON.stringify(value); return String(value); }; + const getFieldName = (key: string) => { + return fieldLabels[key] || key.charAt(0).toUpperCase() + key.slice(1).replace(/_/g, ' '); + }; + + // Helper to check if a key is a snapshot name field + const isSnapshotField = (key: string) => { + return ['category_name', 'base_unit_name', 'large_unit_name', 'purchase_unit_name', 'warehouse_name', 'product_name'].includes(key); + }; + + // Helper to get formatted value (merging ID and Name if available) + const getFormattedValue = (key: string, value: any, allData: Record) => { + // If it's an ID field, check if we have a corresponding name snapshot + if (key.endsWith('_id')) { + const nameKey = key.replace('_id', '_name'); + const nameValue = allData[nameKey]; + if (nameValue) { + return `${nameValue}`; + } + } + return formatValue(key, value); + }; + return ( - - - 操作詳情 - + + + 操作詳情 + {getEventLabel(activity.event)} - - {activity.created_at} 由 {activity.causer} 執行的操作 - + + {/* Modern Metadata Strip */} +
+
+ + {activity.causer} +
+
+ + {activity.created_at} +
+
+ + {activity.subject_type} +
+ {/* Only show 'description' if it differs from event name (unlikely but safe) */} + {activity.description !== getEventLabel(activity.event) && + activity.description !== 'created' && activity.description !== 'updated' && ( +
+ + {activity.description} +
+ )} +
-
-
-
- 操作對象: - {activity.subject_type} -
-
- 描述: - {activity.description} -
-
- +
{activity.event === 'created' ? ( -
- 已新增資料 (初始建立) +
+
+
欄位
+
初始內容
+
+ +
+ {filteredKeys + .filter(key => attributes[key] !== null && attributes[key] !== '' && !isSnapshotField(key)) + .map((key) => ( +
+
{getFieldName(key)}
+
+ {getFormattedValue(key, attributes[key], attributes)} +
+
+ ))} + {filteredKeys.filter(key => attributes[key] !== null && attributes[key] !== '' && !isSnapshotField(key)).length === 0 && ( +
+ 無初始資料 +
+ )} +
+
) : ( -
-
+
+
欄位
異動前
異動後
- {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); + {filteredKeys.some(key => !isSnapshotField(key)) ? ( +
+ {filteredKeys + .filter(key => !isSnapshotField(key)) + .map((key) => { + const oldValue = old[key]; + const newValue = attributes[key]; + const isChanged = JSON.stringify(oldValue) !== JSON.stringify(newValue); - return ( -
-
{key}
-
- {formatValue(oldValue)} + return ( +
+
{getFieldName(key)}
+
+ {getFormattedValue(key, oldValue, old)} +
+
+ {activity.event === 'deleted' ? '-' : getFormattedValue(key, newValue, attributes)} +
-
- {activity.event === 'deleted' ? '-' : formatValue(newValue)} -
-
- ); - })} + ); + })}
) : (
diff --git a/resources/js/Pages/Admin/ActivityLog/Index.tsx b/resources/js/Pages/Admin/ActivityLog/Index.tsx index 9769522..3730bd0 100644 --- a/resources/js/Pages/Admin/ActivityLog/Index.tsx +++ b/resources/js/Pages/Admin/ActivityLog/Index.tsx @@ -14,7 +14,6 @@ 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'; @@ -55,12 +54,12 @@ export default function ActivityLogIndex({ activities, filters }: Props) { const [selectedActivity, setSelectedActivity] = useState(null); const [detailOpen, setDetailOpen] = useState(false); - const getEventBadgeColor = (event: string) => { + const getEventBadgeClass = (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'; + case 'created': return 'bg-green-50 text-green-700 border-green-200 hover:bg-green-100'; + case 'updated': return 'bg-blue-50 text-blue-700 border-blue-200 hover:bg-blue-100'; + case 'deleted': return 'bg-red-50 text-red-700 border-red-200 hover:bg-red-100'; + default: return 'bg-gray-50 text-gray-700 border-gray-200 hover:bg-gray-100'; } }; @@ -73,13 +72,69 @@ export default function ActivityLogIndex({ activities, filters }: Props) { } }; + const getDescription = (activity: Activity) => { + const props = activity.properties || {}; + const attrs = props.attributes || {}; + const old = props.old || {}; + + // Try to find a name in attributes or old values + // Priority: specific name fields > generic name > code > ID + const nameParams = ['product_name', 'warehouse_name', 'category_name', 'base_unit_name', 'name', 'code', 'title']; + let subjectName = ''; + + // Special handling for Inventory: show "Warehouse - Product" + if (attrs.warehouse_name && attrs.product_name) { + subjectName = `${attrs.warehouse_name} - ${attrs.product_name}`; + } else if (old.warehouse_name && old.product_name) { + subjectName = `${old.warehouse_name} - ${old.product_name}`; + } else { + // Default fallback + for (const param of nameParams) { + if (attrs[param]) { + subjectName = attrs[param]; + break; + } + if (old[param]) { + subjectName = old[param]; + break; + } + } + } + + // If no name found, try ID but format it nicely if possible, or just don't show it if it's redundant with subject_type + if (!subjectName && (attrs.id || old.id)) { + subjectName = `#${attrs.id || old.id}`; + } + + // Combine parts: [Causer] [Action] [Name] [Subject] + // Example: Admin 新增 可樂 商品 + // Example: Admin 更新 台北倉 - 可樂 庫存 + return ( + + {activity.causer} + {getEventLabel(activity.event)} + {subjectName && ( + + {subjectName} + + )} + {activity.subject_type} + + {/* Display reason/source if available (e.g., from Replenishment) */} + {(attrs._reason || old._reason) && ( + + (來自 {attrs._reason || old._reason}) + + )} + + ); + }; + const handleViewDetail = (activity: Activity) => { setSelectedActivity(activity); setDetailOpen(true); }; - - const handlePerPageChange = (value: string) => { setPerPage(value); router.get( @@ -155,9 +210,9 @@ export default function ActivityLogIndex({ activities, filters }: Props) { 操作人員 - 動作 - 對象 描述 + 動作 + 對象 操作 @@ -174,24 +229,19 @@ export default function ActivityLogIndex({ activities, filters }: Props) { {activity.causer} + + {getDescription(activity)} + - + {getEventLabel(activity.event)} - + {activity.subject_type} - -
- {activity.causer} - 執行了 - {activity.description} - 動作 -
-