From 5be4d496799c40d84e5ad0cb08b6cc14cf7974f5 Mon Sep 17 00:00:00 2001 From: sky121113 Date: Thu, 12 Feb 2026 16:30:34 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=BF=AE=E6=AD=A3=20BOM=20=E5=96=AE?= =?UTF-8?q?=E4=BD=8D=E9=A1=AF=E7=A4=BA=E8=88=87=E5=AE=8C=E5=B7=A5=E5=85=A5?= =?UTF-8?q?=E5=BA=AB=E5=BD=88=E7=AA=97=20UI=20=E7=B5=B1=E4=B8=80=E8=A6=8F?= =?UTF-8?q?=E7=AF=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .agent/rules/framework.md | 8 +- .../Controllers/ProductionOrderController.php | 226 ++++----- .../Production/Models/ProductionOrder.php | 53 ++ app/Modules/Production/Routes/web.php | 6 + ..._production_order_approval_permissions.php | 49 ++ ...status_enum_in_production_orders_table.php | 34 ++ ...er_nullable_on_production_orders_table.php | 28 ++ ...es_nullable_on_production_orders_table.php | 30 ++ database/seeders/PermissionSeeder.php | 2 + .../ProductionOrderStatusBadge.tsx | 46 ++ .../ProductionStatusProgressBar.tsx | 94 ++++ .../WarehouseSelectionModal.tsx | 147 ++++++ .../js/Components/ui/searchable-select.tsx | 18 +- resources/js/Pages/Production/Create.tsx | 181 +++---- resources/js/Pages/Production/Edit.tsx | 196 ++------ resources/js/Pages/Production/Index.tsx | 65 +-- resources/js/Pages/Production/Show.tsx | 463 ++++++++++++------ resources/js/constants/production-order.ts | 41 ++ resources/js/lib/date.ts | 41 ++ resources/js/lib/utils.ts | 7 + 20 files changed, 1186 insertions(+), 549 deletions(-) create mode 100644 database/migrations/tenant/2026_02_12_143000_add_production_order_approval_permissions.php create mode 100644 database/migrations/tenant/2026_02_12_144220_update_status_enum_in_production_orders_table.php create mode 100644 database/migrations/tenant/2026_02_12_152105_make_output_batch_number_nullable_on_production_orders_table.php create mode 100644 database/migrations/tenant/2026_02_12_154532_make_dates_nullable_on_production_orders_table.php create mode 100644 resources/js/Components/ProductionOrder/ProductionOrderStatusBadge.tsx create mode 100644 resources/js/Components/ProductionOrder/ProductionStatusProgressBar.tsx create mode 100644 resources/js/Components/ProductionOrder/WarehouseSelectionModal.tsx create mode 100644 resources/js/constants/production-order.ts create mode 100644 resources/js/lib/date.ts diff --git a/.agent/rules/framework.md b/.agent/rules/framework.md index eca2e04..f1c5b2d 100644 --- a/.agent/rules/framework.md +++ b/.agent/rules/framework.md @@ -84,4 +84,10 @@ trigger: always_on * **執行 PHP 指令**: `./vendor/bin/sail php -v` * **執行 Artisan 指令**: `./vendor/bin/sail artisan route:list` * **執行 Composer**: `./vendor/bin/sail composer install` -* **執行 Node/NPM**: `./vendor/bin/sail npm run dev` \ No newline at end of file +* **執行 Node/NPM**: `./vendor/bin/sail npm run dev` + +## 10. 日期處理 (Date Handling) + +- 前端顯示日期時預設使用 `resources/js/lib/date.ts` 提供的 `formatDate` 工具。 +- 避免直接顯示原始 ISO 字串(如 `...Z` 結尾的格式)。 +- **智慧格式切換**:`formatDate` 會自動判斷原始資料,若時間部分為 `00:00:00` 則僅顯示 `YYYY-MM-DD`,否則顯示 `YYYY-MM-DD HH:mm:ss`。 \ No newline at end of file diff --git a/app/Modules/Production/Controllers/ProductionOrderController.php b/app/Modules/Production/Controllers/ProductionOrderController.php index 41af550..fe69c93 100644 --- a/app/Modules/Production/Controllers/ProductionOrderController.php +++ b/app/Modules/Production/Controllers/ProductionOrderController.php @@ -106,23 +106,16 @@ class ProductionOrderController extends Controller { $status = $request->input('status', 'draft'); - $baseRules = [ + $rules = [ 'product_id' => 'required', - 'output_batch_number' => 'required|string|max:50', 'status' => 'nullable|in:draft,completed', + 'warehouse_id' => $status === 'completed' ? 'required' : 'nullable', + 'output_quantity' => $status === 'completed' ? 'required|numeric|min:0.01' : 'nullable|numeric', + 'items' => 'nullable|array', + 'items.*.inventory_id' => $status === 'completed' ? 'required' : 'nullable', + 'items.*.quantity_used' => $status === 'completed' ? 'required|numeric|min:0.0001' : 'nullable|numeric', ]; - $completedRules = [ - 'warehouse_id' => 'required', - 'output_quantity' => 'required|numeric|min:0.01', - 'production_date' => 'required|date', - 'items' => 'required|array|min:1', - 'items.*.inventory_id' => 'required', - 'items.*.quantity_used' => 'required|numeric|min:0.0001', - ]; - - $rules = $status === 'completed' ? array_merge($baseRules, $completedRules) : $baseRules; - $validated = $request->validate($rules); DB::transaction(function () use ($validated, $request, $status) { @@ -132,12 +125,12 @@ class ProductionOrderController extends Controller 'product_id' => $validated['product_id'], 'warehouse_id' => $validated['warehouse_id'] ?? null, 'output_quantity' => $validated['output_quantity'] ?? 0, - 'output_batch_number' => $validated['output_batch_number'], + 'output_batch_number' => $request->output_batch_number, // 建立時改為選填 'output_box_count' => $request->output_box_count, - 'production_date' => $validated['production_date'] ?? now()->toDateString(), + 'production_date' => $request->production_date, 'expiry_date' => $request->expiry_date, 'user_id' => auth()->id(), - 'status' => $status, + 'status' => ProductionOrder::STATUS_DRAFT, // 一律存為草稿 'remark' => $request->remark, ]); @@ -155,43 +148,12 @@ class ProductionOrderController extends Controller 'quantity_used' => $item['quantity_used'] ?? 0, 'unit_id' => $item['unit_id'] ?? null, ]); - - if ($status === 'completed') { - $this->inventoryService->decreaseInventoryQuantity( - $item['inventory_id'], - $item['quantity_used'], - "生產單 #{$productionOrder->code} 耗料", - ProductionOrder::class, - $productionOrder->id - ); - } } } - - // 3. 成品入庫 - if ($status === 'completed') { - $this->inventoryService->createInventoryRecord([ - 'warehouse_id' => $validated['warehouse_id'], - 'product_id' => $validated['product_id'], - 'quantity' => $validated['output_quantity'], - 'batch_number' => $validated['output_batch_number'], - 'box_number' => $request->output_box_count, - 'arrival_date' => $validated['production_date'], - 'expiry_date' => $request->expiry_date, - 'reason' => "生產單 #{$productionOrder->code} 成品入庫", - 'reference_type' => ProductionOrder::class, - 'reference_id' => $productionOrder->id, - ]); - - activity() - ->performedOn($productionOrder) - ->causedBy(auth()->user()) - ->log('completed'); - } }); return redirect()->route('production-orders.index') - ->with('success', $status === 'completed' ? '生產單已完成並入庫' : '草稿已儲存'); + ->with('success', '生產單草稿已建立'); } /** @@ -204,7 +166,9 @@ class ProductionOrderController extends Controller if ($productionOrder->product) { $productionOrder->product->base_unit = $this->inventoryService->getUnits()->where('id', $productionOrder->product->base_unit_id)->first(); } - $productionOrder->warehouse = $this->inventoryService->getWarehouse($productionOrder->warehouse_id); + $productionOrder->warehouse = $productionOrder->warehouse_id + ? $this->inventoryService->getWarehouse($productionOrder->warehouse_id) + : null; $productionOrder->user = $this->coreService->getUser($productionOrder->user_id); // 手動水和明細資料 @@ -214,7 +178,7 @@ class ProductionOrderController extends Controller // 修正: 移除跨模組關聯 sourcePurchaseOrder.vendor $inventories = $this->inventoryService->getInventoriesByIds( $inventoryIds, - ['product.baseUnit'] + ['product.baseUnit', 'warehouse'] )->keyBy('id'); // 手動載入 Purchase Orders @@ -238,6 +202,7 @@ class ProductionOrderController extends Controller return Inertia::render('Production/Show', [ 'productionOrder' => $productionOrder, + 'warehouses' => $this->inventoryService->getAllWarehouses(), ]); } @@ -308,7 +273,9 @@ class ProductionOrderController extends Controller // 基本水和 $productionOrder->product = $this->inventoryService->getProduct($productionOrder->product_id); - $productionOrder->warehouse = $this->inventoryService->getWarehouse($productionOrder->warehouse_id); + $productionOrder->warehouse = $productionOrder->warehouse_id + ? $this->inventoryService->getWarehouse($productionOrder->warehouse_id) + : null; // 手動水和明細資料 $items = $productionOrder->items; @@ -346,39 +313,27 @@ class ProductionOrderController extends Controller $status = $request->input('status', 'draft'); // 基礎驗證規則 - $baseRules = [ - 'product_id' => 'required|exists:products,id', - 'output_batch_number' => 'required|string|max:50', - 'status' => 'required|in:draft,completed', + $rules = [ + 'product_id' => 'required', 'remark' => 'nullable|string', + 'warehouse_id' => 'nullable', + 'output_quantity' => 'nullable|numeric', + 'items' => 'nullable|array', + 'items.*.inventory_id' => 'required', + 'items.*.quantity_used' => 'required|numeric', ]; - - // 完工時的嚴格驗證規則 - $completedRules = [ - 'warehouse_id' => 'required|exists:warehouses,id', - 'output_quantity' => 'required|numeric|min:0.01', - 'production_date' => 'required|date', - 'expiry_date' => 'nullable|date', - 'items' => 'required|array|min:1', - 'items.*.inventory_id' => 'required|exists:inventories,id', - 'items.*.quantity_used' => 'required|numeric|min:0.0001', - ]; - - // 若狀態切換為 completed,需合併驗證規則 - $rules = $status === 'completed' ? array_merge($baseRules, $completedRules) : $baseRules; $validated = $request->validate($rules); - DB::transaction(function () use ($validated, $request, $status, $productionOrder) { + DB::transaction(function () use ($validated, $request, $productionOrder) { $productionOrder->update([ 'product_id' => $validated['product_id'], 'warehouse_id' => $validated['warehouse_id'] ?? $productionOrder->warehouse_id, 'output_quantity' => $validated['output_quantity'] ?? 0, - 'output_batch_number' => $validated['output_batch_number'], + 'output_batch_number' => $request->output_batch_number ?? $productionOrder->output_batch_number, 'output_box_count' => $request->output_box_count, - 'production_date' => $validated['production_date'] ?? now()->toDateString(), - 'expiry_date' => $request->expiry_date, - 'status' => $status, + 'production_date' => $request->production_date ?? $productionOrder->production_date, + 'expiry_date' => $request->expiry_date ?? $productionOrder->expiry_date, 'remark' => $request->remark, ]); @@ -398,38 +353,8 @@ class ProductionOrderController extends Controller 'quantity_used' => $item['quantity_used'] ?? 0, 'unit_id' => $item['unit_id'] ?? null, ]); - - if ($status === 'completed') { - $this->inventoryService->decreaseInventoryQuantity( - $item['inventory_id'], - $item['quantity_used'], - "生產單 #{$productionOrder->code} 耗料", - ProductionOrder::class, - $productionOrder->id - ); - } } } - - if ($status === 'completed') { - $this->inventoryService->createInventoryRecord([ - 'warehouse_id' => $validated['warehouse_id'], - 'product_id' => $validated['product_id'], - 'quantity' => $validated['output_quantity'], - 'batch_number' => $validated['output_batch_number'], - 'box_number' => $request->output_box_count, - 'arrival_date' => $validated['production_date'], - 'expiry_date' => $request->expiry_date, - 'reason' => "生產單 #{$productionOrder->code} 成品入庫", - 'reference_type' => ProductionOrder::class, - 'reference_id' => $productionOrder->id, - ]); - - activity() - ->performedOn($productionOrder) - ->causedBy(auth()->user()) - ->log('completed'); - } }); return redirect()->route('production-orders.index') @@ -437,23 +362,102 @@ class ProductionOrderController extends Controller } /** - * 刪除生產單 + * 更新生產工單狀態 + */ + public function updateStatus(Request $request, ProductionOrder $productionOrder) + { + $newStatus = $request->input('status'); + + if (!$productionOrder->canTransitionTo($newStatus)) { + return response()->json(['error' => '不合法的狀態轉移或權限不足'], 403); + } + + DB::transaction(function () use ($newStatus, $productionOrder, $request) { + $oldStatus = $productionOrder->status; + + // 1. 執行特定狀態的業務邏輯 + if ($oldStatus === ProductionOrder::STATUS_APPROVED && $newStatus === ProductionOrder::STATUS_IN_PROGRESS) { + // 開始製作 -> 扣除原料庫存 + $items = $productionOrder->items; + foreach ($items as $item) { + $this->inventoryService->decreaseInventoryQuantity( + $item->inventory_id, + $item->quantity_used, + "生產單 #{$productionOrder->code} 開始製作 (扣料)", + ProductionOrder::class, + $productionOrder->id + ); + } + } + elseif ($oldStatus === ProductionOrder::STATUS_IN_PROGRESS && $newStatus === ProductionOrder::STATUS_COMPLETED) { + // 完成製作 -> 成品入庫 + $warehouseId = $request->input('warehouse_id'); // 由前端 Modal 傳來 + $batchNumber = $request->input('output_batch_number'); // 由前端 Modal 傳來 + $expiryDate = $request->input('expiry_date'); // 由前端 Modal 傳來 + + if (!$warehouseId) { + throw new \Exception('必須選擇入庫倉庫'); + } + if (!$batchNumber) { + throw new \Exception('必須提供成品批號'); + } + + // 更新單據資訊:批號、效期與自動記錄生產日期 + $productionOrder->output_batch_number = $batchNumber; + $productionOrder->expiry_date = $expiryDate; + $productionOrder->production_date = now()->toDateString(); + $productionOrder->warehouse_id = $warehouseId; + + $this->inventoryService->createInventoryRecord([ + 'warehouse_id' => $warehouseId, + 'product_id' => $productionOrder->product_id, + 'quantity' => $productionOrder->output_quantity, + 'batch_number' => $batchNumber, + 'box_number' => $productionOrder->output_box_count, + 'arrival_date' => now()->toDateString(), + 'expiry_date' => $expiryDate, + 'reason' => "生產單 #{$productionOrder->code} 製作完成 (入庫)", + 'reference_type' => ProductionOrder::class, + 'reference_id' => $productionOrder->id, + ]); + } + + // 2. 更新狀態 + $productionOrder->status = $newStatus; + $productionOrder->save(); + + // 3. 紀錄 Activity Log + activity() + ->performedOn($productionOrder) + ->causedBy(auth()->user()) + ->withProperties([ + 'old_status' => $oldStatus, + 'new_status' => $newStatus + ]) + ->log("status_updated_to_{$newStatus}"); + }); + + return back()->with('success', '狀態已更新'); + } + + /** + * 從儲存體中移除指定資源。 */ public function destroy(ProductionOrder $productionOrder) { - if ($productionOrder->status === 'completed') { - return redirect()->back()->with('error', '已完工的生產單無法刪除'); + // 僅允許刪除草稿或已作廢的單據 + if (!in_array($productionOrder->status, [ProductionOrder::STATUS_DRAFT, ProductionOrder::STATUS_CANCELLED])) { + return redirect()->back()->with('error', '僅有草稿或已作廢的生產單可以刪除'); } DB::transaction(function () use ($productionOrder) { - // 紀錄刪除動作 (需在刪除前或使用軟刪除) + $productionOrder->items()->delete(); + $productionOrder->delete(); + activity() ->performedOn($productionOrder) ->causedBy(auth()->user()) ->log('deleted'); - - $productionOrder->items()->delete(); - $productionOrder->delete(); }); return redirect()->route('production-orders.index')->with('success', '生產單已刪除'); diff --git a/app/Modules/Production/Models/ProductionOrder.php b/app/Modules/Production/Models/ProductionOrder.php index 60fbe20..1197842 100644 --- a/app/Modules/Production/Models/ProductionOrder.php +++ b/app/Modules/Production/Models/ProductionOrder.php @@ -11,6 +11,14 @@ class ProductionOrder extends Model { use HasFactory, LogsActivity; + // 狀態常數 + const STATUS_DRAFT = 'draft'; + const STATUS_PENDING = 'pending'; + const STATUS_APPROVED = 'approved'; + const STATUS_IN_PROGRESS = 'in_progress'; + const STATUS_COMPLETED = 'completed'; + const STATUS_CANCELLED = 'cancelled'; + protected $fillable = [ 'code', 'product_id', @@ -25,6 +33,51 @@ class ProductionOrder extends Model 'remark', ]; + /** + * 檢查是否可以轉移至新狀態,並驗證權限。 + */ + public function canTransitionTo(string $newStatus, $user = null): bool + { + $user = $user ?? auth()->user(); + if (!$user) return false; + if ($user->hasRole('super-admin')) return true; + + $currentStatus = $this->status; + + // 定義合法的狀態轉移路徑與所需權限 + $transitions = [ + self::STATUS_DRAFT => [ + self::STATUS_PENDING => 'production_orders.view', // 基本檢視者即可送審 + self::STATUS_CANCELLED => 'production_orders.cancel', + ], + self::STATUS_PENDING => [ + self::STATUS_APPROVED => 'production_orders.approve', + self::STATUS_DRAFT => 'production_orders.approve', // 退回草稿 + self::STATUS_CANCELLED => 'production_orders.cancel', + ], + self::STATUS_APPROVED => [ + self::STATUS_IN_PROGRESS => 'production_orders.edit', // 啟動製作需要編輯權限 + self::STATUS_CANCELLED => 'production_orders.cancel', + ], + self::STATUS_IN_PROGRESS => [ + self::STATUS_COMPLETED => 'production_orders.edit', // 完成製作需要編輯權限 + self::STATUS_CANCELLED => 'production_orders.cancel', + ], + ]; + + if (!isset($transitions[$currentStatus])) { + return false; + } + + if (!array_key_exists($newStatus, $transitions[$currentStatus])) { + return false; + } + + $requiredPermission = $transitions[$currentStatus][$newStatus]; + + return $requiredPermission ? $user->can($requiredPermission) : true; + } + protected $casts = [ 'production_date' => 'date', 'expiry_date' => 'date', diff --git a/app/Modules/Production/Routes/web.php b/app/Modules/Production/Routes/web.php index b0ceb15..ba87643 100644 --- a/app/Modules/Production/Routes/web.php +++ b/app/Modules/Production/Routes/web.php @@ -23,6 +23,12 @@ Route::middleware('auth')->group(function () { Route::get('/production-orders/{productionOrder}/edit', [ProductionOrderController::class, 'edit'])->name('production-orders.edit'); Route::put('/production-orders/{productionOrder}', [ProductionOrderController::class, 'update'])->name('production-orders.update'); }); + + Route::patch('/production-orders/{productionOrder}/update-status', [ProductionOrderController::class, 'updateStatus'])->name('production-orders.update-status'); + + Route::middleware('permission:production_orders.delete')->group(function () { + Route::delete('/production-orders/{productionOrder}', [ProductionOrderController::class, 'destroy'])->name('production-orders.destroy'); + }); }); // 生產管理 API diff --git a/database/migrations/tenant/2026_02_12_143000_add_production_order_approval_permissions.php b/database/migrations/tenant/2026_02_12_143000_add_production_order_approval_permissions.php new file mode 100644 index 0000000..7ed76c5 --- /dev/null +++ b/database/migrations/tenant/2026_02_12_143000_add_production_order_approval_permissions.php @@ -0,0 +1,49 @@ + '核准生產工單', + 'production_orders.cancel' => '作廢生產工單', + ]; + + foreach ($permissions as $name => $description) { + Permission::firstOrCreate( + ['name' => $name, 'guard_name' => $guard], + ['name' => $name, 'guard_name' => $guard] + ); + } + + // 授予 super-admin 所有新權限 + $superAdmin = Role::where('name', 'super-admin')->first(); + if ($superAdmin) { + $superAdmin->givePermissionTo(array_keys($permissions)); + } + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + $permissions = [ + 'production_orders.approve', + 'production_orders.cancel', + ]; + + foreach ($permissions as $name) { + Permission::where('name', $name)->delete(); + } + } +}; diff --git a/database/migrations/tenant/2026_02_12_144220_update_status_enum_in_production_orders_table.php b/database/migrations/tenant/2026_02_12_144220_update_status_enum_in_production_orders_table.php new file mode 100644 index 0000000..56447c8 --- /dev/null +++ b/database/migrations/tenant/2026_02_12_144220_update_status_enum_in_production_orders_table.php @@ -0,0 +1,34 @@ +enum('status', ['draft', 'pending', 'approved', 'in_progress', 'completed', 'cancelled']) + ->default('draft') + ->comment('狀態:草稿/待審/核准/製作中/完成/取消') + ->change(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('production_orders', function (Blueprint $table) { + $table->enum('status', ['draft', 'completed', 'cancelled']) + ->default('completed') + ->comment('狀態:草稿/完成/取消') + ->change(); + }); + } +}; diff --git a/database/migrations/tenant/2026_02_12_152105_make_output_batch_number_nullable_on_production_orders_table.php b/database/migrations/tenant/2026_02_12_152105_make_output_batch_number_nullable_on_production_orders_table.php new file mode 100644 index 0000000..db98318 --- /dev/null +++ b/database/migrations/tenant/2026_02_12_152105_make_output_batch_number_nullable_on_production_orders_table.php @@ -0,0 +1,28 @@ +string('output_batch_number')->nullable()->change(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('production_orders', function (Blueprint $table) { + $table->string('output_batch_number')->nullable(false)->change(); + }); + } +}; diff --git a/database/migrations/tenant/2026_02_12_154532_make_dates_nullable_on_production_orders_table.php b/database/migrations/tenant/2026_02_12_154532_make_dates_nullable_on_production_orders_table.php new file mode 100644 index 0000000..309cf7f --- /dev/null +++ b/database/migrations/tenant/2026_02_12_154532_make_dates_nullable_on_production_orders_table.php @@ -0,0 +1,30 @@ +date('production_date')->nullable()->change(); + $table->date('expiry_date')->nullable()->change(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('production_orders', function (Blueprint $table) { + $table->date('production_date')->nullable(false)->change(); + $table->date('expiry_date')->nullable(false)->change(); + }); + } +}; diff --git a/database/seeders/PermissionSeeder.php b/database/seeders/PermissionSeeder.php index c161f66..7ab10d1 100644 --- a/database/seeders/PermissionSeeder.php +++ b/database/seeders/PermissionSeeder.php @@ -77,6 +77,8 @@ class PermissionSeeder extends Seeder 'production_orders.create' => '建立', 'production_orders.edit' => '編輯', 'production_orders.delete' => '刪除', + 'production_orders.approve' => '核准', + 'production_orders.cancel' => '作廢', // 配方管理 'recipes.view' => '檢視', diff --git a/resources/js/Components/ProductionOrder/ProductionOrderStatusBadge.tsx b/resources/js/Components/ProductionOrder/ProductionOrderStatusBadge.tsx new file mode 100644 index 0000000..54414bb --- /dev/null +++ b/resources/js/Components/ProductionOrder/ProductionOrderStatusBadge.tsx @@ -0,0 +1,46 @@ +/** + * 生產工單狀態標籤組件 + */ + +import { Badge } from "@/Components/ui/badge"; +import { ProductionOrderStatus, STATUS_CONFIG } from "@/constants/production-order"; + +interface ProductionOrderStatusBadgeProps { + status: ProductionOrderStatus; + className?: string; +} + +export default function ProductionOrderStatusBadge({ + status, + className, +}: ProductionOrderStatusBadgeProps) { + const config = STATUS_CONFIG[status] || { label: "未知", variant: "outline" }; + + const getStatusStyles = (status: string) => { + switch (status) { + case 'draft': + return 'bg-gray-100 text-gray-600 border-gray-200'; + case 'pending': + return 'bg-blue-50 text-blue-600 border-blue-200'; + case 'approved': + return 'bg-primary text-primary-foreground border-transparent'; + case 'in_progress': + return 'bg-amber-50 text-amber-600 border-amber-200'; + case 'completed': + return 'bg-primary text-primary-foreground border-transparent transition-all shadow-sm'; + case 'cancelled': + return 'bg-destructive text-destructive-foreground border-transparent'; + default: + return 'bg-gray-50 text-gray-500 border-gray-200'; + } + }; + + return ( + + {config.label} + + ); +} diff --git a/resources/js/Components/ProductionOrder/ProductionStatusProgressBar.tsx b/resources/js/Components/ProductionOrder/ProductionStatusProgressBar.tsx new file mode 100644 index 0000000..06e78ab --- /dev/null +++ b/resources/js/Components/ProductionOrder/ProductionStatusProgressBar.tsx @@ -0,0 +1,94 @@ +/** + * 生產工單狀態流程條組件 + */ + +import { Check } from "lucide-react"; +import { ProductionOrderStatus, PRODUCTION_ORDER_STATUS } from "@/constants/production-order"; + +interface ProductionStatusProgressBarProps { + currentStatus: ProductionOrderStatus; +} + +// 流程步驟定義 +const FLOW_STEPS: { key: ProductionOrderStatus; label: string }[] = [ + { key: PRODUCTION_ORDER_STATUS.DRAFT, label: "草稿" }, + { key: PRODUCTION_ORDER_STATUS.PENDING, label: "簽核中" }, + { key: PRODUCTION_ORDER_STATUS.APPROVED, label: "已核准" }, + { key: PRODUCTION_ORDER_STATUS.IN_PROGRESS, label: "製作中" }, + { key: PRODUCTION_ORDER_STATUS.COMPLETED, label: "製作完成" }, +]; + +export function ProductionStatusProgressBar({ currentStatus }: ProductionStatusProgressBarProps) { + // 對於已作廢狀態,我們顯示到它作廢前的最後一個有效狀態(通常顯示到核准後或簽核中) + // 這裡我們比照採購單邏輯,如果已作廢,可能停在最後一個有效位置 + const effectiveStatus = currentStatus === PRODUCTION_ORDER_STATUS.CANCELLED ? PRODUCTION_ORDER_STATUS.PENDING : currentStatus; + + // 找到當前狀態在流程中的位置 + const currentIndex = FLOW_STEPS.findIndex((step) => step.key === effectiveStatus); + + return ( +
+

生產工單處理進度

+
+ {/* 進度條背景 */} +
+ + {/* 進度條進度 */} + {currentIndex >= 0 && ( +
+ )} + + {/* 步驟標記 */} +
+ {FLOW_STEPS.map((step, index) => { + const isCompleted = index < currentIndex; + const isCurrent = index === currentIndex; + const isRejectedAtThisStep = currentStatus === PRODUCTION_ORDER_STATUS.CANCELLED && step.key === PRODUCTION_ORDER_STATUS.PENDING; + + return ( +
+ {/* 圓點 */} +
+ {isCompleted && !isRejectedAtThisStep ? ( + + ) : ( + {index + 1} + )} +
+ + {/* 標籤 */} +
+

+ {isRejectedAtThisStep ? "已作廢" : step.label} +

+
+
+ ); + })} +
+
+
+ ); +} diff --git a/resources/js/Components/ProductionOrder/WarehouseSelectionModal.tsx b/resources/js/Components/ProductionOrder/WarehouseSelectionModal.tsx new file mode 100644 index 0000000..c500cb6 --- /dev/null +++ b/resources/js/Components/ProductionOrder/WarehouseSelectionModal.tsx @@ -0,0 +1,147 @@ +/** + * 生產工單完工入庫 - 選擇倉庫彈窗 + */ + +import React from 'react'; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/Components/ui/dialog"; +import { Button } from "@/Components/ui/button"; +import { SearchableSelect } from "@/Components/ui/searchable-select"; +import { Input } from "@/Components/ui/input"; +import { Label } from "@/Components/ui/label"; +import { Warehouse as WarehouseIcon, Calendar as CalendarIcon, Tag, X, CheckCircle2 } from "lucide-react"; + +interface Warehouse { + id: number; + name: string; +} + +interface WarehouseSelectionModalProps { + isOpen: boolean; + onClose: () => void; + onConfirm: (data: { + warehouseId: number; + batchNumber: string; + expiryDate: string; + }) => void; + warehouses: Warehouse[]; + processing?: boolean; + // 新增商品資訊以利產生批號 + productCode?: string; + productId?: number; +} + +export default function WarehouseSelectionModal({ + isOpen, + onClose, + onConfirm, + warehouses, + processing = false, + productCode, + productId, +}: WarehouseSelectionModalProps) { + const [selectedId, setSelectedId] = React.useState(null); + const [batchNumber, setBatchNumber] = React.useState(""); + const [expiryDate, setExpiryDate] = React.useState(""); + + // 當開啟時,嘗試產生成品批號 (若有資訊) + React.useEffect(() => { + if (isOpen && productCode && productId) { + const today = new Date().toISOString().split('T')[0].replace(/-/g, ''); + const originCountry = 'TW'; + + // 先放一個預設值,實際序號由後端在儲存時再次確認或提供 API + fetch(`/api/warehouses/${selectedId || warehouses[0]?.id || 1}/inventory/batches/${productId}?originCountry=${originCountry}&arrivalDate=${new Date().toISOString().split('T')[0]}`) + .then(res => res.json()) + .then(result => { + const seq = result.nextSequence || '01'; + setBatchNumber(`${productCode}-${originCountry}-${today}-${seq}`); + }) + .catch(() => { + setBatchNumber(`${productCode}-${originCountry}-${today}-01`); + }); + } + }, [isOpen, productCode, productId]); + + const handleConfirm = () => { + if (selectedId && batchNumber) { + onConfirm({ + warehouseId: selectedId, + batchNumber, + expiryDate + }); + } + }; + + return ( + !open && onClose()}> + + + + + 選擇完工入庫倉庫 + + +
+
+ + ({ value: w.id.toString(), label: w.name }))} + value={selectedId?.toString() || ""} + onValueChange={(val) => setSelectedId(parseInt(val))} + placeholder="請選擇倉庫..." + className="w-full" + /> +
+ +
+ + setBatchNumber(e.target.value)} + placeholder="輸入成品批號" + className="h-9 font-mono" + /> +
+ +
+ + setExpiryDate(e.target.value)} + className="h-9" + /> +
+
+ + + + +
+
+ ); +} diff --git a/resources/js/Components/ui/searchable-select.tsx b/resources/js/Components/ui/searchable-select.tsx index 8e411b9..0236bf3 100644 --- a/resources/js/Components/ui/searchable-select.tsx +++ b/resources/js/Components/ui/searchable-select.tsx @@ -1,5 +1,5 @@ import * as React from "react"; -import { Check, ChevronsUpDown } from "lucide-react"; +import { Check, ChevronsUpDown, X } from "lucide-react"; import { cn } from "@/lib/utils"; import { @@ -36,6 +36,8 @@ interface SearchableSelectProps { searchThreshold?: number; /** 強制控制是否顯示搜尋框。若設定此值,則忽略 searchThreshold */ showSearch?: boolean; + /** 是否可清除選取 */ + isClearable?: boolean; } export function SearchableSelect({ @@ -49,6 +51,7 @@ export function SearchableSelect({ className, searchThreshold = 10, showSearch, + isClearable = false, }: SearchableSelectProps) { const [open, setOpen] = React.useState(false); @@ -86,7 +89,18 @@ export function SearchableSelect({ {selectedOption ? selectedOption.label : placeholder} - +
+ {isClearable && value && !disabled && ( + { + e.stopPropagation(); + onValueChange(""); + }} + /> + )} + +
(""); // 產出倉庫 // 快取對照表:product_id -> inventories across warehouses const [productInventoryMap, setProductInventoryMap] = useState>({}); @@ -100,11 +100,7 @@ export default function ProductionCreate({ products, warehouses }: Props) { product_id: "", warehouse_id: "", output_quantity: "", - output_batch_number: "", - // 移除 Box Count UI - // 移除相關邏輯 - production_date: new Date().toISOString().split('T')[0], - expiry_date: "", + // 移除成品批號、生產日期、有效日期,這些欄位改在完工時處理或自動記錄 remark: "", items: [] as { inventory_id: number; quantity_used: number; unit_id: number | null }[], }); @@ -184,13 +180,14 @@ export default function ProductionCreate({ products, warehouses }: Props) { } } - // 2. 當選擇來源倉庫變更時 -> 重置批號與相關資訊 + // 2. 當選擇來源倉庫變更時 -> 重置批號與相關資訊 (保留數量) if (field === 'ui_warehouse_id') { item.inventory_id = ""; - item.quantity_used = ""; - item.unit_id = ""; - item.ui_input_quantity = ""; - item.ui_selected_unit = "base"; + // 不重置數量 + // item.quantity_used = ""; + // item.ui_input_quantity = ""; + // item.ui_selected_unit = "base"; + // 清除某些 cache delete item.ui_batch_number; delete item.ui_available_qty; @@ -215,6 +212,11 @@ export default function ProductionCreate({ products, warehouses }: Props) { // 預設單位 item.ui_selected_unit = 'base'; item.unit_id = String(inv.base_unit_id || ''); + + // 不重置數量,但如果原本沒數量可以從庫存帶入 (選填,通常配方已帶入則保留配方) + if (!item.ui_input_quantity) { + item.ui_input_quantity = formatQuantity(inv.quantity); + } } } @@ -298,27 +300,6 @@ export default function ProductionCreate({ products, warehouses }: Props) { useEffect(() => { if (!data.product_id) return; - // 1. 自動產生成品批號 - const product = products.find(p => String(p.id) === data.product_id); - if (product) { - const datePart = data.production_date; - const dateFormatted = datePart.replace(/-/g, ''); - const originCountry = 'TW'; - const warehouseId = selectedWarehouse || (warehouses.length > 0 ? String(warehouses[0].id) : '1'); - - fetch(`/api/warehouses/${warehouseId}/inventory/batches/${product.id}?originCountry=${originCountry}&arrivalDate=${datePart}`) - .then(res => res.json()) - .then(result => { - const seq = result.nextSequence || '01'; - const suggested = `${product.code}-${originCountry}-${dateFormatted}-${seq}`; - setData('output_batch_number', suggested); - }) - .catch(() => { - const suggested = `${product.code}-${originCountry}-${dateFormatted}-01`; - setData('output_batch_number', suggested); - }); - } - // 2. 自動載入配方列表 const fetchRecipes = async () => { try { @@ -362,9 +343,7 @@ export default function ProductionCreate({ products, warehouses }: Props) { const missingFields = []; if (!data.product_id) missingFields.push('成品商品'); if (!data.output_quantity) missingFields.push('生產數量'); - if (!data.output_batch_number) missingFields.push('成品批號'); - if (!data.production_date) missingFields.push('生產日期'); - if (!selectedWarehouse) missingFields.push('入庫倉庫'); + if (!selectedWarehouse) missingFields.push('預計入庫倉庫'); if (bomItems.length === 0) missingFields.push('原物料明細'); if (missingFields.length > 0) { @@ -387,6 +366,7 @@ export default function ProductionCreate({ products, warehouses }: Props) { // 使用 router.post 提交完整資料 router.post(route('production-orders.store'), { ...data, + warehouse_id: selectedWarehouse ? parseInt(selectedWarehouse) : null, items: formattedItems, status: status, }, { @@ -430,25 +410,14 @@ export default function ProductionCreate({ products, warehouses }: Props) { 建立新的生產排程,選擇原物料並記錄產出

-
- - -
+
@@ -499,56 +468,16 @@ export default function ProductionCreate({ products, warehouses }: Props) { setData('output_quantity', e.target.value)} placeholder="例如: 50" - className="h-9" + className="h-9 font-mono" /> {errors.output_quantity &&

{errors.output_quantity}

}
- - setData('output_batch_number', e.target.value)} - placeholder="選擇商品後自動產生" - className="h-9 font-mono" - /> - {errors.output_batch_number &&

{errors.output_batch_number}

} -
- - - -
- -
- - setData('production_date', e.target.value)} - className="h-9 pl-9" - /> -
- {errors.production_date &&

{errors.production_date}

} -
- -
- -
- - setData('expiry_date', e.target.value)} - className="h-9 pl-9" - /> -
-
- -
- + - 商品 * - 來源倉庫 * - 批號 * - 數量 * - 單位 - + 商品 * + 來源倉庫 * + 批號 * + 數量 * + 單位 + @@ -618,7 +547,7 @@ export default function ProductionCreate({ products, warehouses }: Props) { value: String(p.id) })); - // 2. 來源倉庫選項 (根據商品库庫存過濾) + // 2. 來源倉庫選項 (根據商品庫存過濾) const currentInventories = productInventoryMap[item.ui_product_id] || []; const filteredWarehouseOptions = Array.from(new Map( currentInventories.map((inv: InventoryOption) => [inv.warehouse_id, { label: inv.warehouse_name, value: String(inv.warehouse_id) }]) @@ -629,12 +558,13 @@ export default function ProductionCreate({ products, warehouses }: Props) { ? filteredWarehouseOptions : (item.ui_product_id && !loadingProducts[item.ui_product_id] ? [] : []); - // 3. 批號選項 (根據商品與倉庫過濾) + // 3. 批號選項 (利用 sublabel 顯示詳細資訊,保持選中後簡潔) const batchOptions = currentInventories .filter((inv: InventoryOption) => String(inv.warehouse_id) === item.ui_warehouse_id) .map((inv: InventoryOption) => ({ - label: `${inv.batch_number} / ${inv.expiry_date || '無效期'} / ${inv.quantity}`, - value: String(inv.id) + label: inv.batch_number, + value: String(inv.id), + sublabel: `(存:${inv.quantity} | 效:${inv.expiry_date || '無'})` })); return ( @@ -678,11 +608,16 @@ export default function ProductionCreate({ products, warehouses }: Props) { /> {item.inventory_id && (() => { const selectedInv = currentInventories.find((i: InventoryOption) => String(i.id) === item.inventory_id); - if (selectedInv) return ( -
- 有效日期: {selectedInv.expiry_date || '無'} | 庫存: {selectedInv.quantity} -
- ); + if (selectedInv) { + const isInsufficient = selectedInv.quantity < parseFloat(item.ui_input_quantity || '0'); + return ( +
+ 有效日期: {selectedInv.expiry_date || '無'} | + 庫存: {formatQuantity(selectedInv.quantity)} + {isInsufficient && ' (庫存不足!)'} +
+ ); + } return null; })()} @@ -692,7 +627,7 @@ export default function ProductionCreate({ products, warehouses }: Props) { updateBomItem(index, 'ui_input_quantity', e.target.value)} placeholder="0" className="h-9 text-right" @@ -700,20 +635,22 @@ export default function ProductionCreate({ products, warehouses }: Props) { /> - {/* 4. 選擇單位 */} - - {item.ui_base_unit_name} + {/* 4. 單位 */} + +
+ {item.ui_base_unit_name || '-'} +
- diff --git a/resources/js/Pages/Production/Edit.tsx b/resources/js/Pages/Production/Edit.tsx index 9615f01..2743cde 100644 --- a/resources/js/Pages/Production/Edit.tsx +++ b/resources/js/Pages/Production/Edit.tsx @@ -4,10 +4,11 @@ */ import { useState, useEffect } from "react"; -import { Factory, Plus, Trash2, ArrowLeft, Save, Calendar } from 'lucide-react'; +import { Trash2, Plus, ArrowLeft, Save, Factory } from "lucide-react"; import { Button } from "@/Components/ui/button"; +import { formatQuantity } from "@/lib/utils"; import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout"; -import { Head, router, useForm, Link } from "@inertiajs/react"; +import { router, useForm, Head, Link } from "@inertiajs/react"; import toast, { Toaster } from 'react-hot-toast'; import { getBreadcrumbs } from "@/utils/breadcrumb"; import { SearchableSelect } from "@/Components/ui/searchable-select"; @@ -107,10 +108,6 @@ interface ProductionOrder { product_id: number; warehouse_id: number | null; output_quantity: number; - output_batch_number: string; - output_box_count: string | null; - production_date: string; - expiry_date: string | null; remark: string | null; status: string; items: ProductionOrderItem[]; @@ -126,18 +123,9 @@ interface Props { } export default function ProductionEdit({ productionOrder, products, warehouses }: Props) { - // 日期格式轉換輔助函數 - const formatDate = (dateValue: string | null | undefined): string => { - if (!dateValue) return ''; - // 處理可能的 ISO 格式或 YYYY-MM-DD 格式 - const date = new Date(dateValue); - if (isNaN(date.getTime())) return dateValue; - return date.toISOString().split('T')[0]; - }; - const [selectedWarehouse, setSelectedWarehouse] = useState( productionOrder.warehouse_id ? String(productionOrder.warehouse_id) : "" - ); // 產出倉庫 + ); // 預計入庫倉庫 // 快取對照表:product_id -> inventories const [productInventoryMap, setProductInventoryMap] = useState>({}); @@ -169,7 +157,7 @@ export default function ProductionEdit({ productionOrder, products, warehouses } // UI Initial State (復原) ui_warehouse_id: item.inventory?.warehouse_id ? String(item.inventory.warehouse_id) : "", ui_product_id: item.inventory ? String(item.inventory.product_id) : "", - ui_input_quantity: String(item.quantity_used), // 假設已存的資料是基本單位 + ui_input_quantity: formatQuantity(item.quantity_used), ui_selected_unit: 'base', // UI 輔助 @@ -183,11 +171,7 @@ export default function ProductionEdit({ productionOrder, products, warehouses } const { data, setData, processing, errors } = useForm({ product_id: String(productionOrder.product_id), warehouse_id: productionOrder.warehouse_id ? String(productionOrder.warehouse_id) : "", - output_quantity: productionOrder.output_quantity ? String(productionOrder.output_quantity) : "", - output_batch_number: productionOrder.output_batch_number || "", - output_box_count: productionOrder.output_box_count || "", - production_date: formatDate(productionOrder.production_date) || new Date().toISOString().split('T')[0], - expiry_date: formatDate(productionOrder.expiry_date), + output_quantity: productionOrder.output_quantity ? formatQuantity(productionOrder.output_quantity) : "", remark: productionOrder.remark || "", items: [] as { inventory_id: number; quantity_used: number; unit_id: number | null }[], }); @@ -210,7 +194,7 @@ export default function ProductionEdit({ productionOrder, products, warehouses } if (inv) { return { ...item, - ui_warehouse_id: String(inv.warehouse_id), // 重要:還原倉庫 ID + ui_warehouse_id: String(inv.warehouse_id), ui_product_name: inv.product_name, ui_batch_number: inv.batch_number, ui_available_qty: inv.quantity, @@ -255,7 +239,7 @@ export default function ProductionEdit({ productionOrder, products, warehouses } const updated = [...bomItems]; const item = { ...updated[index], [field]: value }; - // 0. 當選擇商品變更時 (第一層) + // 0. 當選擇商品變更時 if (field === 'ui_product_id') { item.ui_warehouse_id = ""; item.inventory_id = ""; @@ -263,7 +247,6 @@ export default function ProductionEdit({ productionOrder, products, warehouses } item.unit_id = ""; item.ui_input_quantity = ""; item.ui_selected_unit = "base"; - // 保留基本資訊 if (value) { const prod = products.find(p => String(p.id) === value); if (prod) { @@ -274,16 +257,12 @@ export default function ProductionEdit({ productionOrder, products, warehouses } } } - // 1. 當選擇來源倉庫變更時 (第二層) + // 1. 當選擇來源倉庫變更時 if (field === 'ui_warehouse_id') { item.inventory_id = ""; - item.quantity_used = ""; - item.unit_id = ""; - item.ui_input_quantity = ""; - item.ui_selected_unit = "base"; } - // 2. 當選擇批號 (Inventory) 變更時 (第三層) + // 2. 當選擇批號 (Inventory) 變更時 if (field === 'inventory_id' && value) { const currentOptions = productInventoryMap[item.ui_product_id] || []; const inv = currentOptions.find(i => String(i.id) === value); @@ -302,6 +281,10 @@ export default function ProductionEdit({ productionOrder, products, warehouses } item.ui_selected_unit = 'base'; item.unit_id = String(inv.base_unit_id || ''); + + if (!item.ui_input_quantity) { + item.ui_input_quantity = String(inv.quantity); + } } } @@ -332,18 +315,13 @@ export default function ProductionEdit({ productionOrder, products, warehouses } }))); }, [bomItems]); - // 提交表單(完成模式) - // 提交表單(完成模式) - // 提交表單(完成模式) + // 提交表單 const submit = (status: 'draft' | 'completed') => { - // 驗證(簡單前端驗證) if (status === 'completed') { const missingFields = []; if (!data.product_id) missingFields.push('成品商品'); if (!data.output_quantity) missingFields.push('生產數量'); - if (!data.output_batch_number) missingFields.push('成品批號'); - if (!data.production_date) missingFields.push('生產日期'); - if (!selectedWarehouse) missingFields.push('入庫倉庫'); + if (!selectedWarehouse) missingFields.push('預計入庫倉庫'); if (bomItems.length === 0) missingFields.push('原物料明細'); if (missingFields.length > 0) { @@ -384,24 +362,22 @@ export default function ProductionEdit({ productionOrder, products, warehouses } const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); - submit('completed'); + submit('draft'); }; - - return ( -
+
@@ -409,38 +385,27 @@ export default function ProductionEdit({ productionOrder, products, warehouses }

- 編輯生產工單 + 編輯生產工單:{productionOrder.code}

- 編輯工單內容與排程 + 僅限草稿狀態可進行內容修正

-
- {/* 成品資訊 */}
-

成品資訊

@@ -463,64 +428,16 @@ export default function ProductionEdit({ productionOrder, products, warehouses } setData('output_quantity', e.target.value)} placeholder="例如: 50" - className="h-9" + className="h-9 font-mono" /> {errors.output_quantity &&

{errors.output_quantity}

}
- - setData('output_batch_number', e.target.value)} - placeholder="例如: AB-TW-20260122-01" - className="h-9 font-mono" - /> - {errors.output_batch_number &&

{errors.output_batch_number}

} -
- -
- - setData('output_box_count', e.target.value)} - placeholder="例如: 10" - className="h-9" - /> -
- -
- -
- - setData('production_date', e.target.value)} - className="h-9 pl-9" - /> -
- {errors.production_date &&

{errors.production_date}

} -
- -
- -
- - setData('expiry_date', e.target.value)} - className="h-9 pl-9" - /> -
-
- -
- +
- {/* BOM 原物料明細 */}

原物料使用明細 (BOM)

@@ -574,23 +490,21 @@ export default function ProductionEdit({ productionOrder, products, warehouses } - 商品 * - 來源倉庫 * - 批號 * + 商品 * + 來源倉庫 * + 批號 * 數量 * - 單位 - + 單位 + {bomItems.map((item, index) => { - // 1. 商品選項 const productOptions = products.map(p => ({ label: `${p.name} (${p.code})`, value: String(p.id) })); - // 2. 來源倉庫選項 (根據商品库庫存過濾) const currentInventories = productInventoryMap[item.ui_product_id] || []; const filteredWarehouseOptions = Array.from(new Map( currentInventories.map((inv: InventoryOption) => [inv.warehouse_id, { label: inv.warehouse_name, value: String(inv.warehouse_id) }]) @@ -600,26 +514,22 @@ export default function ProductionEdit({ productionOrder, products, warehouses } ? filteredWarehouseOptions : (item.ui_product_id && !loadingProducts[item.ui_product_id] ? [] : []); - // 備案 (初始載入時) const displayWarehouseOptions = uniqueWarehouseOptions.length > 0 ? uniqueWarehouseOptions : (item.ui_warehouse_id ? [{ label: "載入中...", value: item.ui_warehouse_id }] : []); - // 3. 批號選項 (根據商品與倉庫過濾) const batchOptions = currentInventories .filter((inv: InventoryOption) => String(inv.warehouse_id) === item.ui_warehouse_id) .map((inv: InventoryOption) => ({ - label: `${inv.batch_number} / ${inv.expiry_date || '無效期'} / ${inv.quantity}`, - value: String(inv.id) + label: inv.batch_number, + value: String(inv.id), + sublabel: `(存:${inv.quantity} | 效:${inv.expiry_date || '無'})` })); - // 備案 const displayBatchOptions = batchOptions.length > 0 ? batchOptions : (item.inventory_id && item.ui_batch_number ? [{ label: item.ui_batch_number, value: item.inventory_id }] : []); - return ( - {/* 1. 選擇商品 */} - {/* 2. 選擇來源倉庫 */} - {/* 3. 選擇批號 */} {item.inventory_id && (() => { const selectedInv = currentInventories.find((i: InventoryOption) => String(i.id) === item.inventory_id); - if (selectedInv) return ( -
- 有效日期: {selectedInv.expiry_date || '無'} | 庫存: {selectedInv.quantity} -
- ); + if (selectedInv) { + const isInsufficient = selectedInv.quantity < parseFloat(item.ui_input_quantity || '0'); + return ( +
+ 有效日期: {selectedInv.expiry_date || '無'} | + 庫存: {formatQuantity(selectedInv.quantity)} + {isInsufficient && ' (庫存不足!)'} +
+ ); + } return null; })()}
- {/* 3. 輸入數量 */} updateBomItem(index, 'ui_input_quantity', e.target.value)} placeholder="0" className="h-9 text-right" @@ -680,20 +592,20 @@ export default function ProductionEdit({ productionOrder, products, warehouses } /> - {/* 4. 選擇單位 */} - - {item.ui_base_unit_name} + +
+ {item.ui_base_unit_name || '-'} +
- - diff --git a/resources/js/Pages/Production/Index.tsx b/resources/js/Pages/Production/Index.tsx index d23793b..9860d8f 100644 --- a/resources/js/Pages/Production/Index.tsx +++ b/resources/js/Pages/Production/Index.tsx @@ -4,6 +4,7 @@ import { useState, useEffect } from "react"; import { Plus, Factory, Search, Eye, Pencil, Trash2 } from 'lucide-react'; +import { formatQuantity } from "@/lib/utils"; import { Button } from "@/Components/ui/button"; import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout"; import { Head, router, Link } from "@inertiajs/react"; @@ -20,8 +21,9 @@ import { SelectTrigger, SelectValue, } from "@/Components/ui/select"; -import { Badge } from "@/Components/ui/badge"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/Components/ui/table"; +import ProductionOrderStatusBadge from "@/Components/ProductionOrder/ProductionOrderStatusBadge"; +import { formatDate } from "@/lib/date"; interface ProductionOrder { id: number; @@ -32,7 +34,7 @@ interface ProductionOrder { output_batch_number: string; output_quantity: number; production_date: string; - status: 'draft' | 'completed' | 'cancelled'; + status: 'draft' | 'pending' | 'approved' | 'in_progress' | 'completed' | 'cancelled'; created_at: string; } @@ -51,11 +53,15 @@ interface Props { }; } -const statusConfig: Record = { - draft: { label: "草稿", variant: "secondary" }, - completed: { label: "已完成", variant: "default" }, - cancelled: { label: "已取消", variant: "destructive" }, -}; +const statusOptions = [ + { value: 'all', label: '全部狀態' }, + { value: 'draft', label: '草稿' }, + { value: 'pending', label: '審核中' }, + { value: 'approved', label: '已核准' }, + { value: 'in_progress', label: '製作中' }, + { value: 'completed', label: '已完成' }, + { value: 'cancelled', label: '已取消' }, +]; export default function ProductionIndex({ productionOrders, filters }: Props) { const [search, setSearch] = useState(filters.search || ""); @@ -154,10 +160,11 @@ export default function ProductionIndex({ productionOrders, filters }: Props) { - 全部狀態 - 草稿 - 已完成 - 已取消 + {statusOptions.map(opt => ( + + {opt.label} + + ))} @@ -230,18 +237,16 @@ export default function ProductionIndex({ productionOrders, filters }: Props) { - {order.output_quantity.toLocaleString()} + {formatQuantity(order.output_quantity)} {order.warehouse?.name || '-'} - {order.production_date} + {formatDate(order.production_date)} - - {statusConfig[order.status]?.label || order.status} - +
@@ -272,19 +277,21 @@ export default function ProductionIndex({ productionOrders, filters }: Props) { - + {(order.status === 'draft' || order.status === 'cancelled') && ( + + )}
diff --git a/resources/js/Pages/Production/Show.tsx b/resources/js/Pages/Production/Show.tsx index 6e5f009..089ca34 100644 --- a/resources/js/Pages/Production/Show.tsx +++ b/resources/js/Pages/Production/Show.tsx @@ -3,15 +3,28 @@ * 含追溯資訊:成品批號 → 原物料批號 → 來源採購單 */ -import { Factory, ArrowLeft, Package, Calendar, User, Warehouse, FileText, Link2 } from 'lucide-react'; +import { Factory, ArrowLeft, Package, Calendar, User, Warehouse, FileText, Link2, Send, CheckCircle2, PlayCircle, Ban, ArrowRightCircle } from 'lucide-react'; +import { formatQuantity } from "@/lib/utils"; import { Button } from "@/Components/ui/button"; import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout"; -import { Head, Link } from "@inertiajs/react"; +import { Head, Link, useForm, router } from "@inertiajs/react"; import { getBreadcrumbs } from "@/utils/breadcrumb"; -import { Badge } from "@/Components/ui/badge"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/Components/ui/table"; +import { Badge } from "@/Components/ui/badge"; +import ProductionOrderStatusBadge from '@/Components/ProductionOrder/ProductionOrderStatusBadge'; +import { ProductionStatusProgressBar } from '@/Components/ProductionOrder/ProductionStatusProgressBar'; +import { PRODUCTION_ORDER_STATUS, ProductionOrderStatus } from '@/constants/production-order'; +import WarehouseSelectionModal from '@/Components/ProductionOrder/WarehouseSelectionModal'; +import { useState } from 'react'; +import { formatDate } from '@/lib/date'; + +interface Warehouse { + id: number; + name: string; +} interface ProductionOrderItem { + // ... (後面保持不變) id: number; quantity_used: number; unit?: { id: number; name: string } | null; @@ -22,6 +35,7 @@ interface ProductionOrderItem { arrival_date: string | null; origin_country: string | null; product: { id: number; name: string; code: string } | null; + warehouse?: { id: number; name: string } | null; source_purchase_order?: { id: number; code: string; @@ -34,14 +48,16 @@ interface ProductionOrder { id: number; code: string; product: { id: number; name: string; code: string; base_unit?: { name: string } | null } | null; + product_id: number; warehouse: { id: number; name: string } | null; + warehouse_id: number | null; user: { id: number; name: string } | null; output_batch_number: string; output_box_count: string | null; output_quantity: number; production_date: string; expiry_date: string | null; - status: 'draft' | 'completed' | 'cancelled'; + status: ProductionOrderStatus; remark: string | null; created_at: string; items: ProductionOrderItem[]; @@ -49,200 +65,363 @@ interface ProductionOrder { interface Props { productionOrder: ProductionOrder; + warehouses: Warehouse[]; + auth: { + user: { + id: number; + name: string; + roles: string[]; + permissions: string[]; + } | null; + }; } -const statusConfig: Record = { - draft: { label: "草稿", variant: "secondary" }, - completed: { label: "已完成", variant: "default" }, - cancelled: { label: "已取消", variant: "destructive" }, -}; +export default function ProductionShow({ productionOrder, warehouses, auth }: Props) { + const [isWarehouseModalOpen, setIsWarehouseModalOpen] = useState(false); + const { processing } = useForm({ + status: '' as ProductionOrderStatus, + warehouse_id: null as number | null, + }); + + const handleStatusUpdate = (newStatus: string, extraData?: { + warehouseId?: number; + batchNumber?: string; + expiryDate?: string; + }) => { + router.patch(route('production-orders.update-status', productionOrder.id), { + status: newStatus, + warehouse_id: extraData?.warehouseId, + output_batch_number: extraData?.batchNumber, + expiry_date: extraData?.expiryDate, + }, { + onSuccess: () => { + setIsWarehouseModalOpen(false); + }, + preserveScroll: true, + }); + }; + + const userPermissions = auth.user?.permissions || []; + const hasPermission = (permission: string) => auth.user?.roles?.includes('super-admin') || userPermissions.includes(permission); + + // 權限判斷 + const canApprove = hasPermission('production_orders.approve'); + const canCancel = hasPermission('production_orders.cancel'); + const canEdit = hasPermission('production_orders.edit'); -export default function ProductionShow({ productionOrder }: Props) { return ( -
+ + setIsWarehouseModalOpen(false)} + onConfirm={(data) => handleStatusUpdate(PRODUCTION_ORDER_STATUS.COMPLETED, data)} + warehouses={warehouses} + processing={processing} + productCode={productionOrder.product?.code} + productId={productionOrder.product?.id} + /> + +
+ {/* Header 區塊 */}
+ {/* 返回按鈕 (統一規範:標題上方,mb-4) */} -
+ +
-

- - {productionOrder.code} -

-

- 生產工單詳情與追溯資訊 +

+

+ + 生產工單:{productionOrder.code} +

+ +
+

+ 建立人員:{productionOrder.user?.name || '-'} | 建立時間:{formatDate(productionOrder.created_at)}

- - {statusConfig[productionOrder.status]?.label || productionOrder.status} - + + {/* 操作按鈕區 (統一規範樣式類別) */} +
+ {/* 草稿 -> 提交審核 */} + {productionOrder.status === PRODUCTION_ORDER_STATUS.DRAFT && ( + <> + {canEdit && ( + + + + )} + + + )} + + {/* 待審核 -> 核准 / 駁回 */} + {productionOrder.status === PRODUCTION_ORDER_STATUS.PENDING && canApprove && ( + <> + + + + )} + + {/* 已核准 -> 開始製作 */} + {productionOrder.status === PRODUCTION_ORDER_STATUS.APPROVED && ( + + )} + + {/* 製作中 -> 完成製作 */} + {productionOrder.status === PRODUCTION_ORDER_STATUS.IN_PROGRESS && ( + + )} + + {/* 可作廢狀態 (非已完成/已作廢/草稿之外) */} + {!([PRODUCTION_ORDER_STATUS.COMPLETED, PRODUCTION_ORDER_STATUS.CANCELLED, PRODUCTION_ORDER_STATUS.DRAFT] as ProductionOrderStatus[]).includes(productionOrder.status) && canCancel && ( + + )} +
- {/* 成品資訊 */} -
-

- - 成品資訊 -

-
-
-

成品商品

-

- {productionOrder.product?.name || '-'} - - ({productionOrder.product?.code || '-'}) - -

-
-
-

成品批號

-

- {productionOrder.output_batch_number} -

-
-
-

生產數量

-

- {productionOrder.output_quantity.toLocaleString()} - {productionOrder.product?.base_unit?.name && ( - {productionOrder.product.base_unit.name} - )} - {productionOrder.output_box_count && ( - ({productionOrder.output_box_count} 箱) - )} -

-
-
-

入庫倉庫

-
- -

{productionOrder.warehouse?.name || '-'}

-
-
-
-

生產日期

-
- -

{productionOrder.production_date}

-
-
-
-

成品效期

-
- -

{productionOrder.expiry_date || '-'}

-
-
-
-

操作人員

-
- -

{productionOrder.user?.name || '-'}

+
+ {/* 狀態進度條 */} +
+ +
+ + {/* 成品資訊 (統一規範:bg-white rounded-xl border border-gray-200 shadow-sm p-6) */} +
+
+

+ + 成品資訊 +

+
+
+

成品商品

+
+

+ {productionOrder.product?.name || '-'} +

+

+ {productionOrder.product?.code || '-'} +

+
+
+
+

生產批號

+

+ {productionOrder.output_batch_number} +

+
+
+

預計/實際產量

+
+

+ {formatQuantity(productionOrder.output_quantity)} +

+ {productionOrder.product?.base_unit?.name && ( + {productionOrder.product.base_unit.name} + )} + {productionOrder.output_box_count && ( + ({productionOrder.output_box_count} 箱) + )} +
+
+
+

入庫倉庫

+
+ +

{productionOrder.warehouse?.name || (productionOrder.status === PRODUCTION_ORDER_STATUS.COMPLETED ? '系統錯誤' : '待選取')}

+
+
+ + {productionOrder.remark && ( +
+
+ +
+

備註資訊

+

{productionOrder.remark}

+
+
+
+ )}
- {productionOrder.remark && ( -
-
- -
-

備註

-

{productionOrder.remark}

+ {/* 次要資訊 */} +
+
+

+ + 時間與人員 +

+ +
+
+ +
+

生產日期

+

{formatDate(productionOrder.production_date)}

+
+
+ +
+ +
+

成品效期

+

{formatDate(productionOrder.expiry_date)}

+
+
+ +
+ +
+

執行人員

+

{productionOrder.user?.name || '-'}

+
- )} +
- {/* 原物料使用明細 (BOM) */} -
-

- - 原物料使用明細 (BOM) - 追溯資訊 -

+ {/* 原物料使用明細 (BOM) (統一規範:bg-white rounded-xl border border-gray-200 shadow-sm p-6) */} +
+
+

+ + 原料耗用與追溯 +

+ + 共 {productionOrder.items.length} 項物料 + +
{productionOrder.items.length === 0 ? ( -

無原物料記錄

+
+ +

無原物料消耗記錄

+
) : ( -
+
- - - - 原物料 - - - 批號 - - - 來源國家 - - - 入庫日期 - - - 使用量 - - - 來源採購單 - + + + 原物料 + 來源倉庫 + 使用批號 + 來源國家 + 使用數量 + 來源單據 {productionOrder.items.map((item) => ( - - -
{item.inventory?.product?.name || '-'}
-
+ + +
{item.inventory?.product?.name || '-'}
+
{item.inventory?.product?.code || '-'}
- - {item.inventory?.batch_number || '-'} - {item.inventory?.box_number && ( - #{item.inventory.box_number} - )} + +
{item.inventory?.warehouse?.name || '-'}
- - {item.inventory?.origin_country || '-'} + +
+ {item.inventory?.batch_number || '-'} + {item.inventory?.box_number && ( + #{item.inventory.box_number} + )} +
- - {item.inventory?.arrival_date || '-'} + + + {item.inventory?.origin_country || '-'} + - - {item.quantity_used.toLocaleString()} - {item.unit?.name && ( - {item.unit.name} - )} + +
+ {formatQuantity(item.quantity_used)} + {item.unit?.name && ( + {item.unit.name} + )} +
- + {item.inventory?.source_purchase_order ? ( -
+
{item.inventory.source_purchase_order.code} + {item.inventory.source_purchase_order.vendor && ( - + {item.inventory.source_purchase_order.vendor.name} )}
) : ( - - + )} diff --git a/resources/js/constants/production-order.ts b/resources/js/constants/production-order.ts new file mode 100644 index 0000000..10edf32 --- /dev/null +++ b/resources/js/constants/production-order.ts @@ -0,0 +1,41 @@ +/** + * 生產工單狀態相關常數 + */ + +export const PRODUCTION_ORDER_STATUS = { + DRAFT: 'draft', + PENDING: 'pending', + APPROVED: 'approved', + IN_PROGRESS: 'in_progress', + COMPLETED: 'completed', + CANCELLED: 'cancelled', +} as const; + +export type ProductionOrderStatus = typeof PRODUCTION_ORDER_STATUS[keyof typeof PRODUCTION_ORDER_STATUS]; + +export const STATUS_CONFIG: Record = { + [PRODUCTION_ORDER_STATUS.DRAFT]: { + label: "草稿", + variant: "outline", + }, + [PRODUCTION_ORDER_STATUS.PENDING]: { + label: "審核中", + variant: "info", + }, + [PRODUCTION_ORDER_STATUS.APPROVED]: { + label: "已核准", + variant: "success", + }, + [PRODUCTION_ORDER_STATUS.IN_PROGRESS]: { + label: "製作中", + variant: "warning", + }, + [PRODUCTION_ORDER_STATUS.COMPLETED]: { + label: "製作完成", + variant: "default", + }, + [PRODUCTION_ORDER_STATUS.CANCELLED]: { + label: "已作廢", + variant: "destructive", + }, +}; diff --git a/resources/js/lib/date.ts b/resources/js/lib/date.ts new file mode 100644 index 0000000..ef3c6e9 --- /dev/null +++ b/resources/js/lib/date.ts @@ -0,0 +1,41 @@ +import { format, parseISO, isValid } from "date-fns"; + +/** + * 格式化日期字串為統一格式 (YYYY-MM-DD HH:mm:ss) + * @param dateStr ISO 格式的日期字串 + * @param formatStr 輸出的格式字串,預設為 "yyyy-MM-dd HH:mm:ss" + * @returns 格式化後的字串,若日期無效則回傳空字串 + */ +export function formatDate( + dateStr: string | null | undefined, + formatStr?: string +): string { + if (!dateStr) return "-"; + + try { + const date = parseISO(dateStr); + if (!isValid(date)) return "-"; + + // 如果使用者有指定格式,則依指定格式輸出 + if (formatStr) { + return format(date, formatStr); + } + + // 智慧判斷:如果時間是 00:00:00 (通常代表後端僅提供日期),則僅顯示日期 + const hasTime = + date.getHours() !== 0 || + date.getMinutes() !== 0 || + date.getSeconds() !== 0; + + return format(date, hasTime ? "yyyy-MM-dd HH:mm:ss" : "yyyy-MM-dd"); + } catch (e) { + return "-"; + } +} + +/** + * 格式化日期字串為僅日期格式 (YYYY-MM-DD) + */ +export function formatDateOnly(dateStr: string | null | undefined): string { + return formatDate(dateStr, "yyyy-MM-dd"); +} diff --git a/resources/js/lib/utils.ts b/resources/js/lib/utils.ts index e6a8be0..a44980c 100644 --- a/resources/js/lib/utils.ts +++ b/resources/js/lib/utils.ts @@ -4,3 +4,10 @@ import { twMerge } from "tailwind-merge"; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); } + +export function formatQuantity(value: number | string): string { + const num = typeof value === 'string' ? parseFloat(value) : value; + if (isNaN(num)) return '0'; + // 使用 Number() 會自動去除末尾無意義的 0 + return String(Number(num.toFixed(4))); +}