diff --git a/app/Http/Controllers/PurchaseOrderController.php b/app/Http/Controllers/PurchaseOrderController.php index 006a588..03720ae 100644 --- a/app/Http/Controllers/PurchaseOrderController.php +++ b/app/Http/Controllers/PurchaseOrderController.php @@ -103,6 +103,7 @@ class PurchaseOrderController extends Controller 'items.*.quantity' => 'required|numeric|min:0.01', 'items.*.subtotal' => 'required|numeric|min:0', // 總金額 'items.*.unitId' => 'nullable|exists:units,id', + 'tax_amount' => 'nullable|numeric|min:0', ]); try { @@ -129,8 +130,8 @@ class PurchaseOrderController extends Controller $totalAmount += $item['subtotal']; } - // Simple tax calculation (e.g., 5%) - $taxAmount = round($totalAmount * 0.05, 2); + // Tax calculation + $taxAmount = isset($validated['tax_amount']) ? $validated['tax_amount'] : round($totalAmount * 0.05, 2); $grandTotal = $totalAmount + $taxAmount; // 確保有一個有效的使用者 ID @@ -325,6 +326,9 @@ class PurchaseOrderController extends Controller 'items.*.quantity' => 'required|numeric|min:0.01', 'items.*.subtotal' => 'required|numeric|min:0', // 總金額 'items.*.unitId' => 'nullable|exists:units,id', + // Allow both tax_amount and taxAmount for compatibility + 'tax_amount' => 'nullable|numeric|min:0', + 'taxAmount' => 'nullable|numeric|min:0', ]); try { @@ -335,11 +339,13 @@ class PurchaseOrderController extends Controller $totalAmount += $item['subtotal']; } - // Simple tax calculation (e.g., 5%) - $taxAmount = round($totalAmount * 0.05, 2); + // Tax calculation (handle both keys) + $inputTax = $validated['tax_amount'] ?? $validated['taxAmount'] ?? null; + $taxAmount = !is_null($inputTax) ? $inputTax : round($totalAmount * 0.05, 2); $grandTotal = $totalAmount + $taxAmount; - $order->update([ + // 1. Fill attributes but don't save yet to capture changes + $order->fill([ 'vendor_id' => $validated['vendor_id'], 'warehouse_id' => $validated['warehouse_id'], 'expected_delivery_date' => $validated['expected_delivery_date'], @@ -353,19 +359,124 @@ class PurchaseOrderController extends Controller 'invoice_amount' => $validated['invoice_amount'] ?? null, ]); - // Sync items + // Capture attribute changes for manual logging + $dirty = $order->getDirty(); + $oldAttributes = []; + $newAttributes = []; + + foreach ($dirty as $key => $value) { + $oldAttributes[$key] = $order->getOriginal($key); + $newAttributes[$key] = $value; + } + + // Save without triggering events (prevents duplicate log) + $order->saveQuietly(); + + // 2. Capture old items with product names for diffing + $oldItems = $order->items()->with('product', 'unit')->get()->map(function($item) { + return [ + 'id' => $item->id, + 'product_id' => $item->product_id, + 'product_name' => $item->product?->name, + 'quantity' => (float) $item->quantity, + 'unit_id' => $item->unit_id, + 'unit_name' => $item->unit?->name, + 'subtotal' => (float) $item->subtotal, + ]; + })->keyBy('product_id'); + + // Sync items (Original logic) $order->items()->delete(); + + $newItemsData = []; foreach ($validated['items'] as $item) { // 反算單價 $unitPrice = $item['quantity'] > 0 ? $item['subtotal'] / $item['quantity'] : 0; - $order->items()->create([ + $newItem = $order->items()->create([ 'product_id' => $item['productId'], 'quantity' => $item['quantity'], 'unit_id' => $item['unitId'] ?? null, 'unit_price' => $unitPrice, 'subtotal' => $item['subtotal'], ]); + $newItemsData[] = $newItem; + } + + // 3. Calculate Item Diffs + $itemDiffs = [ + 'added' => [], + 'removed' => [], + 'updated' => [], + ]; + + // Re-fetch new items to ensure we have fresh relations + $newItemsFormatted = $order->items()->with('product', 'unit')->get()->map(function($item) { + return [ + 'product_id' => $item->product_id, + 'product_name' => $item->product?->name, + 'quantity' => (float) $item->quantity, + 'unit_id' => $item->unit_id, + 'unit_name' => $item->unit?->name, + 'subtotal' => (float) $item->subtotal, + ]; + })->keyBy('product_id'); + + // Find removed + foreach ($oldItems as $productId => $oldItem) { + if (!$newItemsFormatted->has($productId)) { + $itemDiffs['removed'][] = $oldItem; + } + } + + // Find added and updated + foreach ($newItemsFormatted as $productId => $newItem) { + if (!$oldItems->has($productId)) { + $itemDiffs['added'][] = $newItem; + } else { + $oldItem = $oldItems[$productId]; + // Compare fields + if ( + $oldItem['quantity'] != $newItem['quantity'] || + $oldItem['unit_id'] != $newItem['unit_id'] || + $oldItem['subtotal'] != $newItem['subtotal'] + ) { + $itemDiffs['updated'][] = [ + 'product_name' => $newItem['product_name'], + 'old' => [ + 'quantity' => $oldItem['quantity'], + 'unit_name' => $oldItem['unit_name'], + 'subtotal' => $oldItem['subtotal'], + ], + 'new' => [ + 'quantity' => $newItem['quantity'], + 'unit_name' => $newItem['unit_name'], + 'subtotal' => $newItem['subtotal'], + ] + ]; + } + } + } + + // 4. Manually Log activity (Single Consolidated Log) + // Log if there are attribute changes OR item changes + if (!empty($newAttributes) || !empty($itemDiffs['added']) || !empty($itemDiffs['removed']) || !empty($itemDiffs['updated'])) { + activity() + ->performedOn($order) + ->causedBy(auth()->user()) + ->event('updated') + ->withProperties([ + 'attributes' => $newAttributes, + 'old' => $oldAttributes, + 'items_diff' => $itemDiffs, + 'snapshot' => [ + 'po_number' => $order->code, + 'vendor_name' => $order->vendor?->name, + 'warehouse_name' => $order->warehouse?->name, + 'user_name' => $order->user?->name, + ] + ]) + ->log('updated'); } DB::commit(); @@ -383,9 +494,43 @@ class PurchaseOrderController extends Controller try { DB::beginTransaction(); - $order = PurchaseOrder::findOrFail($id); + $order = PurchaseOrder::with(['items.product', 'items.unit'])->findOrFail($id); - // Delete associated items first (due to FK constraints if not cascade) + // Capture items for logging + $items = $order->items->map(function ($item) { + return [ + 'product_name' => $item->product_name, + 'quantity' => floatval($item->quantity), + 'unit_name' => $item->unit_name, + 'subtotal' => floatval($item->subtotal), + ]; + })->toArray(); + + // Manually log the deletion with items + activity() + ->performedOn($order) + ->causedBy(auth()->user()) + ->event('deleted') + ->withProperties([ + 'attributes' => $order->getAttributes(), + 'items_diff' => [ + 'added' => [], + 'removed' => $items, + 'updated' => [], + ], + 'snapshot' => [ + 'po_number' => $order->code, + 'vendor_name' => $order->vendor?->name, + 'warehouse_name' => $order->warehouse?->name, + 'user_name' => $order->user?->name, + ] + ]) + ->log('deleted'); + + // Disable automatic logging for this operation + $order->disableLogging(); + + // Delete associated items first $order->items()->delete(); $order->delete(); diff --git a/app/Http/Controllers/VendorProductController.php b/app/Http/Controllers/VendorProductController.php index d2f824a..6a25f8f 100644 --- a/app/Http/Controllers/VendorProductController.php +++ b/app/Http/Controllers/VendorProductController.php @@ -27,6 +27,25 @@ class VendorProductController extends Controller 'last_price' => $validated['last_price'] ?? null ]); + // 記錄操作 + $product = \App\Models\Product::find($validated['product_id']); + activity() + ->performedOn($vendor) + ->withProperties([ + 'attributes' => [ + 'product_name' => $product->name, + 'last_price' => $validated['last_price'] ?? null, + ], + 'sub_subject' => '供貨商品', + 'snapshot' => [ + 'name' => "{$vendor->name}-{$product->name}", // 顯示例如:台積電-紅糖 + 'vendor_name' => $vendor->name, + 'product_name' => $product->name, + ] + ]) + ->event('created') + ->log('新增供貨商品'); + return redirect()->back()->with('success', '供貨商品已新增'); } @@ -39,10 +58,34 @@ class VendorProductController extends Controller 'last_price' => 'nullable|numeric|min:0', ]); + // 獲取舊價格 + $old_price = $vendor->products()->where('product_id', $productId)->first()?->pivot?->last_price; + $vendor->products()->updateExistingPivot($productId, [ 'last_price' => $validated['last_price'] ?? null ]); + // 記錄操作 + $product = \App\Models\Product::find($productId); + activity() + ->performedOn($vendor) + ->withProperties([ + 'old' => [ + 'last_price' => $old_price, + ], + 'attributes' => [ + 'last_price' => $validated['last_price'] ?? null, + ], + 'sub_subject' => '供貨商品', + 'snapshot' => [ + 'name' => "{$vendor->name}-{$product->name}", + 'vendor_name' => $vendor->name, + 'product_name' => $product->name, + ] + ]) + ->event('updated') + ->log('更新供貨商品價格'); + return redirect()->back()->with('success', '供貨資訊已更新'); } @@ -51,8 +94,31 @@ class VendorProductController extends Controller */ public function destroy(Vendor $vendor, $productId) { + // 記錄操作 (需在 detach 前獲取資訊) + $product = \App\Models\Product::find($productId); + $old_price = $vendor->products()->where('product_id', $productId)->first()?->pivot?->last_price; + $vendor->products()->detach($productId); + if ($product) { + activity() + ->performedOn($vendor) + ->withProperties([ + 'old' => [ + 'product_name' => $product->name, + 'last_price' => $old_price, + ], + 'sub_subject' => '供貨商品', + 'snapshot' => [ + 'name' => "{$vendor->name}-{$product->name}", + 'vendor_name' => $vendor->name, + 'product_name' => $product->name, + ] + ]) + ->event('deleted') + ->log('移除供貨商品'); + } + return redirect()->back()->with('success', '供貨商品已移除'); } } diff --git a/app/Models/Category.php b/app/Models/Category.php index b541701..cedbcc7 100644 --- a/app/Models/Category.php +++ b/app/Models/Category.php @@ -40,9 +40,11 @@ class Category extends Model public function tapActivity(\Spatie\Activitylog\Contracts\Activity $activity, string $eventName) { $properties = $activity->properties; - $attributes = $properties['attributes'] ?? []; - $attributes['name'] = $this->name; - $properties['attributes'] = $attributes; + + $snapshot = $properties['snapshot'] ?? []; + $snapshot['name'] = $this->name; + $properties['snapshot'] = $snapshot; + $activity->properties = $properties; } } diff --git a/app/Models/Inventory.php b/app/Models/Inventory.php index 57661cb..f4fc40f 100644 --- a/app/Models/Inventory.php +++ b/app/Models/Inventory.php @@ -38,11 +38,12 @@ class Inventory extends Model { $properties = $activity->properties; $attributes = $properties['attributes'] ?? []; + $snapshot = $properties['snapshot'] ?? []; // 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); + $snapshot['warehouse_name'] = $this->warehouse ? $this->warehouse->name : ($snapshot['warehouse_name'] ?? null); + $snapshot['product_name'] = $this->product ? $this->product->name : ($snapshot['product_name'] ?? null); // Capture the reason if set if ($this->activityLogReason) { @@ -50,6 +51,7 @@ class Inventory extends Model } $properties['attributes'] = $attributes; + $properties['snapshot'] = $snapshot; $activity->properties = $properties; } diff --git a/app/Models/Product.php b/app/Models/Product.php index 62740c3..42a6ebd 100644 --- a/app/Models/Product.php +++ b/app/Models/Product.php @@ -80,11 +80,12 @@ class Product extends Model { $properties = $activity->properties; $attributes = $properties['attributes'] ?? []; + $snapshot = $properties['snapshot'] ?? []; // Handle Category Name Snapshot if (isset($attributes['category_id'])) { $category = Category::find($attributes['category_id']); - $attributes['category_name'] = $category ? $category->name : null; + $snapshot['category_name'] = $category ? $category->name : null; } // Handle Unit Name Snapshots @@ -93,14 +94,15 @@ class Product extends Model if (isset($attributes[$field])) { $unit = Unit::find($attributes[$field]); $nameKey = str_replace('_id', '_name', $field); - $attributes[$nameKey] = $unit ? $unit->name : null; + $snapshot[$nameKey] = $unit ? $unit->name : null; } } // Always snapshot self name for context (so logs always show "Cola") - $attributes['name'] = $this->name; + $snapshot['name'] = $this->name; $properties['attributes'] = $attributes; + $properties['snapshot'] = $snapshot; $activity->properties = $properties; } diff --git a/app/Models/PurchaseOrder.php b/app/Models/PurchaseOrder.php index d4acc7b..1fa8ff1 100644 --- a/app/Models/PurchaseOrder.php +++ b/app/Models/PurchaseOrder.php @@ -44,6 +44,8 @@ class PurchaseOrder extends Model 'supplierName', 'expectedDate', 'totalAmount', + 'taxAmount', // Add this + 'grandTotal', // Add this 'createdBy', 'warehouse_name', 'createdAt', @@ -82,6 +84,16 @@ class PurchaseOrder extends Model return (float) ($this->attributes['total_amount'] ?? 0); } + public function getTaxAmountAttribute(): float + { + return (float) ($this->attributes['tax_amount'] ?? 0); + } + + public function getGrandTotalAttribute(): float + { + return (float) ($this->attributes['grand_total'] ?? 0); + } + public function getCreatedByAttribute(): string { return $this->user ? $this->user->name : '系統'; @@ -135,4 +147,21 @@ class PurchaseOrder extends Model ->logOnlyDirty() ->dontSubmitEmptyLogs(); } + + public function tapActivity(\Spatie\Activitylog\Contracts\Activity $activity, string $eventName) + { + $properties = $activity->properties; + $attributes = $properties['attributes'] ?? []; + $snapshot = $properties['snapshot'] ?? []; + + // Snapshot key names + $snapshot['po_number'] = $this->code; + $snapshot['vendor_name'] = $this->vendor ? $this->vendor->name : null; + $snapshot['warehouse_name'] = $this->warehouse ? $this->warehouse->name : null; + $snapshot['user_name'] = $this->user ? $this->user->name : null; + + $properties['attributes'] = $attributes; + $properties['snapshot'] = $snapshot; + $activity->properties = $properties; + } } diff --git a/app/Models/Unit.php b/app/Models/Unit.php index a07e93d..dedf256 100644 --- a/app/Models/Unit.php +++ b/app/Models/Unit.php @@ -27,9 +27,11 @@ class Unit extends Model public function tapActivity(\Spatie\Activitylog\Contracts\Activity $activity, string $eventName) { $properties = $activity->properties; - $attributes = $properties['attributes'] ?? []; - $attributes['name'] = $this->name; - $properties['attributes'] = $attributes; + + $snapshot = $properties['snapshot'] ?? []; + $snapshot['name'] = $this->name; + $properties['snapshot'] = $snapshot; + $activity->properties = $properties; } } diff --git a/app/Models/Vendor.php b/app/Models/Vendor.php index 6e0be0d..85e4f12 100644 --- a/app/Models/Vendor.php +++ b/app/Models/Vendor.php @@ -44,4 +44,19 @@ class Vendor extends Model ->logOnlyDirty() ->dontSubmitEmptyLogs(); } + + public function tapActivity(\Spatie\Activitylog\Contracts\Activity $activity, string $eventName) + { + $properties = $activity->properties; + + // Store name in 'snapshot' for context, keeping 'attributes' clean + $snapshot = $properties['snapshot'] ?? []; + // Only set name if it's not already set (e.g. by controller for specific context like supply product) + if (!isset($snapshot['name'])) { + $snapshot['name'] = $this->name; + } + $properties['snapshot'] = $snapshot; + + $activity->properties = $properties; + } } diff --git a/app/Models/Warehouse.php b/app/Models/Warehouse.php index eec28fc..aa7d64e 100644 --- a/app/Models/Warehouse.php +++ b/app/Models/Warehouse.php @@ -29,12 +29,11 @@ class Warehouse extends Model public function tapActivity(\Spatie\Activitylog\Contracts\Activity $activity, string $eventName) { $properties = $activity->properties; - $attributes = $properties['attributes'] ?? []; + + $snapshot = $properties['snapshot'] ?? []; + $snapshot['name'] = $this->name; + $properties['snapshot'] = $snapshot; - // Always snapshot name - $attributes['name'] = $this->name; - - $properties['attributes'] = $attributes; $activity->properties = $properties; } diff --git a/resources/js/Components/ActivityLog/ActivityDetailDialog.tsx b/resources/js/Components/ActivityLog/ActivityDetailDialog.tsx new file mode 100644 index 0000000..9d716d3 --- /dev/null +++ b/resources/js/Components/ActivityLog/ActivityDetailDialog.tsx @@ -0,0 +1,440 @@ +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/Components/ui/dialog"; +import { Badge } from "@/Components/ui/badge"; +import { ScrollArea } from "@/Components/ui/scroll-area"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/Components/ui/table"; +import { User, Clock, Package, Activity as ActivityIcon } from "lucide-react"; + +interface Activity { + id: number; + description: string; + subject_type: string; + event: string; + causer: string; + created_at: string; + properties: { + attributes?: Record; + old?: Record; + snapshot?: Record; + sub_subject?: string; + items_diff?: { + added: any[]; + removed: any[]; + updated: any[]; + }; + }; +} + +interface Props { + open: boolean; + onOpenChange: (open: boolean) => void; + 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: '採購單位名稱', + // Vendor fields + short_name: '簡稱', + tax_id: '統編', + owner: '負責人', + contact_name: '聯絡人', + tel: '電話', + remark: '備註', + // Warehouse & Inventory fields + warehouse_name: '倉庫名稱', + product_name: '商品名稱', + warehouse_id: '倉庫', + product_id: '商品', + quantity: '數量', + safety_stock: '安全庫存', + location: '儲位', + // Purchase Order fields + po_number: '採購單號', + vendor_id: '廠商', + vendor_name: '廠商名稱', + user_name: '建單人員', + user_id: '建單人員', + total_amount: '小計', + expected_delivery_date: '預計到貨日', + status: '狀態', + tax_amount: '稅額', + grand_total: '總計', + invoice_number: '發票號碼', + invoice_date: '發票日期', + invoice_amount: '發票金額', + last_price: '供貨價格', +}; + +// Purchase Order Status Map +const statusMap: Record = { + draft: '草稿', + pending: '待審核', + approved: '已核准', + ordered: '已下單', + received: '已收貨', + cancelled: '已取消', + completed: '已完成', +}; + +export default function ActivityDetailDialog({ open, onOpenChange, activity }: Props) { + if (!activity) return null; + + const attributes = activity.properties?.attributes || {}; + const old = activity.properties?.old || {}; + const snapshot = activity.properties?.snapshot || {}; + + // 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)])); + + // Custom sort order for fields + const sortOrder = [ + 'po_number', 'vendor_name', 'warehouse_name', 'expected_delivery_date', 'status', 'remark', + 'invoice_number', 'invoice_date', 'invoice_amount', + 'total_amount', 'tax_amount', 'grand_total' // Ensure specific order for amounts + ]; + + // 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) + ) + .sort((a, b) => { + const indexA = sortOrder.indexOf(a); + const indexB = sortOrder.indexOf(b); + + // If both are in sortOrder, compare indices + if (indexA !== -1 && indexB !== -1) return indexA - indexB; + // If only A is in sortOrder, it comes first (or wherever logic dictates, usually put known fields first) + if (indexA !== -1) return -1; + if (indexB !== -1) return 1; + // Otherwise alphabetical or default + return a.localeCompare(b); + }); + + // Helper to check if a key is a snapshot name field + + // 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', 'user_name' + ].includes(key); + }; + + const getEventBadgeClass = (event: string) => { + switch (event) { + case 'created': return 'bg-green-50 text-green-700 border-green-200'; + case 'updated': return 'bg-blue-50 text-blue-700 border-blue-200'; + case 'deleted': return 'bg-red-50 text-red-700 border-red-200'; + default: return 'bg-gray-50 text-gray-700 border-gray-200'; + } + }; + + const getEventLabel = (event: string) => { + switch (event) { + case 'created': return '新增'; + case 'updated': return '更新'; + case 'deleted': return '刪除'; + case 'updated_items': return '異動品項'; + default: return event; + } + }; + + const formatValue = (key: string, value: any) => { + if (value === null || value === undefined) return '-'; + if (typeof value === 'boolean') return value ? '是' : '否'; + if (key === 'is_active') return value ? '啟用' : '停用'; + + // Handle Purchase Order Status + if (key === 'status' && typeof value === 'string' && statusMap[value]) { + return statusMap[value]; + } + + // Handle Date Fields (YYYY-MM-DD) + if ((key === 'expected_delivery_date' || key === 'invoice_date') && typeof value === 'string') { + // Take only the date part (YYYY-MM-DD) + return value.split('T')[0].split(' ')[0]; + } + + return String(value); + }; + + const getFormattedValue = (key: string, value: any) => { + // If it's an ID field, try to find a corresponding name in snapshot or attributes + if (key.endsWith('_id')) { + const nameKey = key.replace('_id', '_name'); + // Check snapshot first, then attributes + const nameValue = snapshot[nameKey] || attributes[nameKey]; + if (nameValue) { + return `${nameValue}`; + } + } + return formatValue(key, value); + }; + + // Helper to get translated field label + const getFieldLabel = (key: string) => { + return fieldLabels[key] || key; + }; + + // Get subject name for header + const getSubjectName = () => { + // Special handling for Inventory: show "Warehouse - Product" + if ((snapshot.warehouse_name || attributes.warehouse_name) && (snapshot.product_name || attributes.product_name)) { + const wName = snapshot.warehouse_name || attributes.warehouse_name; + const pName = snapshot.product_name || attributes.product_name; + return `${wName} - ${pName}`; + } + + const nameParams = ['po_number', 'name', 'code', 'product_name', 'warehouse_name', 'category_name', 'base_unit_name', 'title']; + for (const param of nameParams) { + if (snapshot[param]) return snapshot[param]; + if (attributes[param]) return attributes[param]; + if (old[param]) return old[param]; + } + + if (attributes.id || old.id) return `#${attributes.id || old.id}`; + return ''; + }; + + const subjectName = getSubjectName(); + + return ( + + + +
+ + 操作詳情 + + + {getEventLabel(activity.event)} + +
+ + {/* Modern Metadata Strip */} +
+
+ + {activity.causer} +
+
+ + {activity.created_at} +
+
+ + + {subjectName ? `${subjectName} ` : ''} + {activity.properties?.sub_subject || 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.event === 'created' ? ( +
+ + + + 欄位 + 異動前 + 異動後 + + + + {filteredKeys + .filter(key => attributes[key] !== null && attributes[key] !== '' && !isSnapshotField(key)) + .map((key) => ( + + {getFieldLabel(key)} + - + + {getFormattedValue(key, attributes[key])} + + + ))} + {filteredKeys.filter(key => attributes[key] !== null && attributes[key] !== '' && !isSnapshotField(key)).length === 0 && ( + + + 無初始資料 + + + )} + +
+
+ ) : ( +
+ + + + 欄位 + 異動前 + 異動後 + + + + {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); + + // For deleted events, we want to show the current attributes in the "Before" column + const displayBefore = activity.event === 'deleted' + ? getFormattedValue(key, newValue || oldValue) + : getFormattedValue(key, oldValue); + + const displayAfter = activity.event === 'deleted' + ? '-' + : getFormattedValue(key, newValue); + + return ( + + {getFieldLabel(key)} + + {displayBefore} + + + {displayAfter} + + + ); + }) + ) : ( + + + 無詳細異動內容 + + + )} + +
+
+ )} + {/* Items Diff Section (Special for Purchase Orders) */} + {activity.properties?.items_diff && ( +
+

+ + 品項異動明細 +

+ +
+ + + + 商品名稱 + 異動類型 + 異動詳情 (舊 → 新) + + + + {/* Updated Items */} + {activity.properties.items_diff.updated.map((item: any, idx: number) => ( + + {item.product_name} + + 更新 + + +
+ {item.old.quantity !== item.new.quantity && ( +
數量: {item.old.quantity}{item.new.quantity}
+ )} + {item.old.unit_name !== item.new.unit_name && ( +
單位: {item.old.unit_name || '-'}{item.new.unit_name || '-'}
+ )} + {item.old.subtotal !== item.new.subtotal && ( +
小計: ${item.old.subtotal}${item.new.subtotal}
+ )} +
+
+
+ ))} + + {/* Added Items */} + {activity.properties.items_diff.added.map((item: any, idx: number) => ( + + {item.product_name} + + 新增 + + + 數量: {item.quantity} {item.unit_name} / 小計: ${item.subtotal} + + + ))} + + {/* Removed Items */} + {activity.properties.items_diff.removed.map((item: any, idx: number) => ( + + {item.product_name} + + 移除 + + + 原紀錄: {item.quantity} {item.unit_name} + + + ))} +
+
+
+
+ )} +
+
+
+
+ ); +} diff --git a/resources/js/Components/ActivityLog/LogTable.tsx b/resources/js/Components/ActivityLog/LogTable.tsx new file mode 100644 index 0000000..c49778c --- /dev/null +++ b/resources/js/Components/ActivityLog/LogTable.tsx @@ -0,0 +1,213 @@ +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/Components/ui/table"; +import { Badge } from "@/Components/ui/badge"; +import { Eye, ArrowUpDown, ArrowUp, ArrowDown } from 'lucide-react'; +import { Button } from '@/Components/ui/button'; + +export interface Activity { + id: number; + description: string; + subject_type: string; + event: string; + causer: string; + created_at: string; + properties: any; +} + +interface LogTableProps { + activities: Activity[]; + sortField?: string; + sortOrder?: 'asc' | 'desc'; + onSort?: (field: string) => void; + onViewDetail: (activity: Activity) => void; + from?: number; // Starting index number (paginator.from) +} + +export default function LogTable({ + activities, + sortField, + sortOrder, + onSort, + onViewDetail, + from = 1 +}: LogTableProps) { + const getEventBadgeClass = (event: string) => { + switch (event) { + 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'; + } + }; + + const getEventLabel = (event: string) => { + switch (event) { + case 'created': return '新增'; + case 'updated': return '更新'; + case 'deleted': return '刪除'; + default: return event; + } + }; + + const getDescription = (activity: Activity) => { + const props = activity.properties || {}; + const attrs = props.attributes || {}; + const old = props.old || {}; + const snapshot = props.snapshot || {}; + + // Try to find a name in snapshot, attributes or old values + // Priority: snapshot > specific name fields > generic name > code > ID + const nameParams = ['po_number', 'name', 'code', 'product_name', 'warehouse_name', 'category_name', 'base_unit_name', 'title']; + let subjectName = ''; + + // Special handling for Inventory: show "Warehouse - Product" + if ((snapshot.warehouse_name || attrs.warehouse_name) && (snapshot.product_name || attrs.product_name)) { + const wName = snapshot.warehouse_name || attrs.warehouse_name; + const pName = snapshot.product_name || attrs.product_name; + subjectName = `${wName} - ${pName}`; + } else if (old.warehouse_name && old.product_name) { + subjectName = `${old.warehouse_name} - ${old.product_name}`; + } else { + // Default fallback + for (const param of nameParams) { + if (snapshot[param]) { + subjectName = snapshot[param]; + break; + } + 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} + + )} + {props.sub_subject ? ( + {props.sub_subject} + ) : ( + {activity.subject_type} + )} + + {/* Display reason/source if available (e.g., from Replenishment) */} + {(attrs._reason || old._reason) && ( + + (來自 {attrs._reason || old._reason}) + + )} + + ); + }; + + const SortIcon = ({ field }: { field: string }) => { + if (!onSort) return null; + if (sortField !== field) { + return ; + } + if (sortOrder === "asc") { + return ; + } + return ; + }; + + return ( +
+ + + + # + + {onSort ? ( + + ) : ( + "時間" + )} + + 操作人員 + 描述 + 動作 + 對象 + 操作 + + + + {activities.length > 0 ? ( + activities.map((activity, index) => ( + + + {from + index} + + + {activity.created_at} + + + {activity.causer} + + + {getDescription(activity)} + + + + {getEventLabel(activity.event)} + + + + + {activity.subject_type} + + + + + + + )) + ) : ( + + + 尚無操作紀錄 + + + )} + +
+
+ ); +} diff --git a/resources/js/Components/PurchaseOrder/PurchaseOrderActions.tsx b/resources/js/Components/PurchaseOrder/PurchaseOrderActions.tsx index c831fd7..760ca8a 100644 --- a/resources/js/Components/PurchaseOrder/PurchaseOrderActions.tsx +++ b/resources/js/Components/PurchaseOrder/PurchaseOrderActions.tsx @@ -1,23 +1,36 @@ +import { useState } from "react"; import { Pencil, Eye, Trash2 } from "lucide-react"; import { Button } from "@/Components/ui/button"; import { Link, useForm } from "@inertiajs/react"; import type { PurchaseOrder } from "@/types/purchase-order"; import { toast } from "sonner"; import { Can } from "@/Components/Permission/Can"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/Components/ui/alert-dialog"; export function PurchaseOrderActions({ order, }: { order: PurchaseOrder }) { + const [showDeleteDialog, setShowDeleteDialog] = useState(false); const { delete: destroy, processing } = useForm({}); - const handleDelete = () => { - if (confirm(`確定要刪除採購單 ${order.poNumber} 嗎?`)) { - // @ts-ignore - destroy(route('purchase-orders.destroy', order.id), { - onSuccess: () => toast.success("採購單已成功刪除"), - onError: (errors: any) => toast.error(errors.error || "刪除過程中發生錯誤"), - }); - } + const handleConfirmDelete = () => { + // @ts-ignore + destroy(route('purchase-orders.destroy', order.id), { + onSuccess: () => { + toast.success("採購單已成功刪除"); + setShowDeleteDialog(false); + }, + onError: (errors: any) => toast.error(errors.error || "刪除過程中發生錯誤"), + }); }; return ( @@ -50,11 +63,31 @@ export function PurchaseOrderActions({ size="sm" className="button-outlined-error" title="刪除" - onClick={handleDelete} + onClick={() => setShowDeleteDialog(true)} disabled={processing} > + + + + + 確認刪除採購單 + + 確定要刪除採購單 「{order.poNumber}」 嗎?此操作無法撤銷。 + + + + 取消 + + 確認刪除 + + + + ); diff --git a/resources/js/Components/PurchaseOrder/PurchaseOrderItemsTable.tsx b/resources/js/Components/PurchaseOrder/PurchaseOrderItemsTable.tsx index 962767e..a938579 100644 --- a/resources/js/Components/PurchaseOrder/PurchaseOrderItemsTable.tsx +++ b/resources/js/Components/PurchaseOrder/PurchaseOrderItemsTable.tsx @@ -44,7 +44,7 @@ export function PurchaseOrderItemsTable({ 數量 單位 換算基本單位 - 總金額 + 小計 單價 / 基本單位 {!isReadOnly && } diff --git a/resources/js/Components/PurchaseOrder/PurchaseOrderTable.tsx b/resources/js/Components/PurchaseOrder/PurchaseOrderTable.tsx index eafe7a1..98a91ce 100644 --- a/resources/js/Components/PurchaseOrder/PurchaseOrderTable.tsx +++ b/resources/js/Components/PurchaseOrder/PurchaseOrderTable.tsx @@ -161,7 +161,7 @@ export default function PurchaseOrderTable({ onClick={() => handleSort("totalAmount")} className="flex items-center gap-2 ml-auto hover:text-foreground transition-colors" > - 總金額 + 小計 diff --git a/resources/js/Pages/Admin/ActivityLog/ActivityDetailDialog.tsx b/resources/js/Pages/Admin/ActivityLog/ActivityDetailDialog.tsx deleted file mode 100644 index bd65ae6..0000000 --- a/resources/js/Pages/Admin/ActivityLog/ActivityDetailDialog.tsx +++ /dev/null @@ -1,239 +0,0 @@ -import { - Dialog, - DialogContent, - 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; - 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; -} - -// 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; - - 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 getEventBadgeClass = (event: string) => { - switch (event) { - 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'; - } - }; - - const getEventLabel = (event: string) => { - switch (event) { - case 'created': return '新增'; - case 'updated': return '更新'; - case 'deleted': return '刪除'; - default: return event; - } - }; - - const formatValue = (key: string, value: any) => { - if (value === null || value === undefined) return -; - - // 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)} - - - - {/* 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.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.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 ( -
-
{getFieldName(key)}
-
- {getFormattedValue(key, oldValue, old)} -
-
- {activity.event === 'deleted' ? '-' : getFormattedValue(key, newValue, attributes)} -
-
- ); - })} -
- ) : ( -
- 無詳細異動內容 -
- )} -
-
- )} -
-
-
- ); -} diff --git a/resources/js/Pages/Admin/ActivityLog/Index.tsx b/resources/js/Pages/Admin/ActivityLog/Index.tsx index 3730bd0..45ea0a6 100644 --- a/resources/js/Pages/Admin/ActivityLog/Index.tsx +++ b/resources/js/Pages/Admin/ActivityLog/Index.tsx @@ -2,30 +2,11 @@ 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 { 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; -} +import { FileText } from 'lucide-react'; +import LogTable, { Activity } from '@/Components/ActivityLog/LogTable'; +import ActivityDetailDialog from '@/Components/ActivityLog/ActivityDetailDialog'; interface PaginationLinks { url: string | null; @@ -54,82 +35,6 @@ export default function ActivityLogIndex({ activities, filters }: Props) { const [selectedActivity, setSelectedActivity] = useState(null); const [detailOpen, setDetailOpen] = useState(false); - const getEventBadgeClass = (event: string) => { - switch (event) { - 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'; - } - }; - - const getEventLabel = (event: string) => { - switch (event) { - case 'created': return '新增'; - case 'updated': return '更新'; - case 'deleted': return '刪除'; - default: return event; - } - }; - - 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); @@ -164,16 +69,6 @@ export default function ActivityLogIndex({ activities, filters }: Props) { ); }; - const SortIcon = ({ field }: { field: string }) => { - if (filters.sort_by !== field) { - return ; - } - if (filters.sort_order === "asc") { - return ; - } - return ; - }; - return ( -
- - - - # - - - - 操作人員 - 描述 - 動作 - 對象 - 操作 - - - - {activities.data.length > 0 ? ( - activities.data.map((activity, index) => ( - - - {activities.from + index} - - - {activity.created_at} - - - {activity.causer} - - - {getDescription(activity)} - - - - {getEventLabel(activity.event)} - - - - - {activity.subject_type} - - - - - - - )) - ) : ( - - - 尚無操作紀錄 - - - )} - -
-
+
diff --git a/resources/js/Pages/PurchaseOrder/Create.tsx b/resources/js/Pages/PurchaseOrder/Create.tsx index bab8b29..720d8d6 100644 --- a/resources/js/Pages/PurchaseOrder/Create.tsx +++ b/resources/js/Pages/PurchaseOrder/Create.tsx @@ -3,6 +3,7 @@ */ import { ArrowLeft, Plus, Info, ShoppingCart } from "lucide-react"; +import { useEffect } from "react"; import { Button } from "@/Components/ui/button"; import { Input } from "@/Components/ui/input"; import { Textarea } from "@/Components/ui/textarea"; @@ -58,11 +59,23 @@ export default function CreatePurchaseOrder({ setInvoiceNumber, setInvoiceDate, setInvoiceAmount, + taxAmount, + setTaxAmount, + isTaxManual, + setIsTaxManual, } = usePurchaseOrderForm({ order, suppliers }); const totalAmount = calculateTotalAmount(items); + // Auto-calculate tax if not manual + useEffect(() => { + if (!isTaxManual) { + const calculatedTax = Math.round(totalAmount * 0.05); + setTaxAmount(calculatedTax); + } + }, [totalAmount, isTaxManual]); + const handleSave = () => { if (!warehouseId) { toast.error("請選擇入庫倉庫"); @@ -113,6 +126,7 @@ export default function CreatePurchaseOrder({ invoice_number: invoiceNumber || null, invoice_date: invoiceDate || null, invoice_amount: invoiceAmount ? parseFloat(invoiceAmount) : null, + tax_amount: Number(taxAmount) || 0, items: validItems.map(item => ({ productId: item.productId, quantity: item.quantity, @@ -159,20 +173,20 @@ export default function CreatePurchaseOrder({ return ( -
+
{/* Header */} -
+
-
+

{order ? "編輯採購單" : "建立採購單"} @@ -183,7 +197,7 @@ export default function CreatePurchaseOrder({

-
+
{/* 步驟一:基本資訊 */}
@@ -191,7 +205,7 @@ export default function CreatePurchaseOrder({

基本資訊

-
+
-
+
-
+
{!hasSupplier && ( @@ -355,10 +369,53 @@ export default function CreatePurchaseOrder({ /> {hasSupplier && items.length > 0 && ( -
-
- 採購預估總額 - {formatCurrency(totalAmount)} +
+
+
+ 小計 + {formatCurrency(totalAmount)} +
+ +
+
+ 稅額 (5%) + +
+
+ { + setTaxAmount(e.target.value); + setIsTaxManual(true); + }} + className="text-right h-9 bg-white" + placeholder="0" + /> +
+
+ +
+ +
+ 總計 (含稅) + + {formatCurrency(totalAmount + (Number(taxAmount) || 0))} + +
)} @@ -367,9 +424,9 @@ export default function CreatePurchaseOrder({
{/* 底部按鈕 */} -
+
- diff --git a/resources/js/Pages/PurchaseOrder/Show.tsx b/resources/js/Pages/PurchaseOrder/Show.tsx index 18dba22..e7a6df5 100644 --- a/resources/js/Pages/PurchaseOrder/Show.tsx +++ b/resources/js/Pages/PurchaseOrder/Show.tsx @@ -143,11 +143,21 @@ export default function ViewPurchaseOrderPage({ order }: Props) { items={order.items} isReadOnly={true} /> -
- 總金額 - - {formatCurrency(order.totalAmount)} - +
+
+ 小計 + {formatCurrency(order.totalAmount)} +
+
+ 稅額 + {formatCurrency(order.tax_amount || 0)} +
+
+ 總計 + + {formatCurrency(order.grand_total || (order.totalAmount + (order.tax_amount || 0)))} + +
diff --git a/resources/js/hooks/usePurchaseOrderForm.ts b/resources/js/hooks/usePurchaseOrderForm.ts index c89a0b4..041ae27 100644 --- a/resources/js/hooks/usePurchaseOrderForm.ts +++ b/resources/js/hooks/usePurchaseOrderForm.ts @@ -12,28 +12,40 @@ interface UsePurchaseOrderFormProps { } export function usePurchaseOrderForm({ order, suppliers }: UsePurchaseOrderFormProps) { - const [supplierId, setSupplierId] = useState(""); - const [expectedDate, setExpectedDate] = useState(""); - const [items, setItems] = useState([]); - const [notes, setNotes] = useState(""); - const [status, setStatus] = useState("draft"); - const [warehouseId, setWarehouseId] = useState(""); - const [invoiceNumber, setInvoiceNumber] = useState(""); - const [invoiceDate, setInvoiceDate] = useState(""); - const [invoiceAmount, setInvoiceAmount] = useState(""); + const [supplierId, setSupplierId] = useState(order?.supplierId || ""); + const [expectedDate, setExpectedDate] = useState(order?.expectedDate || ""); + const [items, setItems] = useState(order?.items || []); + const [notes, setNotes] = useState(order?.remark || ""); + const [status, setStatus] = useState(order?.status || "draft"); + const [warehouseId, setWarehouseId] = useState(order?.warehouse_id || ""); + const [invoiceNumber, setInvoiceNumber] = useState(order?.invoiceNumber || ""); + const [invoiceDate, setInvoiceDate] = useState(order?.invoiceDate || ""); + const [invoiceAmount, setInvoiceAmount] = useState(order?.invoiceAmount ? String(order.invoiceAmount) : ""); + const [taxAmount, setTaxAmount] = useState( + order?.taxAmount !== undefined && order.taxAmount !== null ? order.taxAmount : + (order?.tax_amount !== undefined && order.tax_amount !== null ? order.tax_amount : "") + ); + const [isTaxManual, setIsTaxManual] = useState(!!(order?.taxAmount !== undefined || order?.tax_amount !== undefined)); - // 載入編輯訂單資料 + // 同步外部傳入的 order 更新 (例如重新執行 edit 路由) useEffect(() => { if (order) { setSupplierId(order.supplierId); setExpectedDate(order.expectedDate); - setItems(order.items); + setItems(order.items || []); setNotes(order.remark || ""); setStatus(order.status); setWarehouseId(order.warehouse_id || ""); setInvoiceNumber(order.invoiceNumber || ""); setInvoiceDate(order.invoiceDate || ""); setInvoiceAmount(order.invoiceAmount ? String(order.invoiceAmount) : ""); + + const val = order.taxAmount !== undefined && order.taxAmount !== null ? order.taxAmount : + (order.tax_amount !== undefined && order.tax_amount !== null ? order.tax_amount : ""); + setTaxAmount(val); + if (val !== "") { + setIsTaxManual(true); + } } }, [order]); @@ -47,6 +59,8 @@ export function usePurchaseOrderForm({ order, suppliers }: UsePurchaseOrderFormP setInvoiceNumber(""); setInvoiceDate(""); setInvoiceAmount(""); + setTaxAmount(""); + setIsTaxManual(false); }; const selectedSupplier = suppliers.find((s) => String(s.id) === String(supplierId)); @@ -154,6 +168,8 @@ export function usePurchaseOrderForm({ order, suppliers }: UsePurchaseOrderFormP invoiceNumber, invoiceDate, invoiceAmount, + taxAmount, + isTaxManual, // Setters setSupplierId, @@ -164,6 +180,8 @@ export function usePurchaseOrderForm({ order, suppliers }: UsePurchaseOrderFormP setInvoiceNumber, setInvoiceDate, setInvoiceAmount, + setTaxAmount, + setIsTaxManual, // Methods addItem, diff --git a/resources/js/types/purchase-order.ts b/resources/js/types/purchase-order.ts index e642a71..f57e30f 100644 --- a/resources/js/types/purchase-order.ts +++ b/resources/js/types/purchase-order.ts @@ -81,6 +81,10 @@ export interface PurchaseOrder { invoiceNumber?: string; // 發票號碼 invoiceDate?: string; // 發票日期 invoiceAmount?: number; // 發票金額 + tax_amount?: number; // 稅額 (DB column) + taxAmount?: number; // 稅額 (Accessor) + grand_total?: number; // 總計 (含稅) (DB column) + grandTotal?: number; // 總計 (含稅) (Accessor) } export interface CommonProduct {