Merge branch 'dev'
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Has been skipped
Koori-ERP-Deploy-System / deploy-production (push) Successful in 51s

This commit is contained in:
2026-02-12 16:31:46 +08:00
20 changed files with 1186 additions and 549 deletions

View File

@@ -84,4 +84,10 @@ trigger: always_on
* **執行 PHP 指令** `./vendor/bin/sail php -v` * **執行 PHP 指令** `./vendor/bin/sail php -v`
* **執行 Artisan 指令** `./vendor/bin/sail artisan route:list` * **執行 Artisan 指令** `./vendor/bin/sail artisan route:list`
* **執行 Composer** `./vendor/bin/sail composer install` * **執行 Composer** `./vendor/bin/sail composer install`
* **執行 Node/NPM** `./vendor/bin/sail npm run dev` * **執行 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`

View File

@@ -106,23 +106,16 @@ class ProductionOrderController extends Controller
{ {
$status = $request->input('status', 'draft'); $status = $request->input('status', 'draft');
$baseRules = [ $rules = [
'product_id' => 'required', 'product_id' => 'required',
'output_batch_number' => 'required|string|max:50',
'status' => 'nullable|in:draft,completed', '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); $validated = $request->validate($rules);
DB::transaction(function () use ($validated, $request, $status) { DB::transaction(function () use ($validated, $request, $status) {
@@ -132,12 +125,12 @@ class ProductionOrderController extends Controller
'product_id' => $validated['product_id'], 'product_id' => $validated['product_id'],
'warehouse_id' => $validated['warehouse_id'] ?? null, 'warehouse_id' => $validated['warehouse_id'] ?? null,
'output_quantity' => $validated['output_quantity'] ?? 0, '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, 'output_box_count' => $request->output_box_count,
'production_date' => $validated['production_date'] ?? now()->toDateString(), 'production_date' => $request->production_date,
'expiry_date' => $request->expiry_date, 'expiry_date' => $request->expiry_date,
'user_id' => auth()->id(), 'user_id' => auth()->id(),
'status' => $status, 'status' => ProductionOrder::STATUS_DRAFT, // 一律存為草稿
'remark' => $request->remark, 'remark' => $request->remark,
]); ]);
@@ -155,43 +148,12 @@ class ProductionOrderController extends Controller
'quantity_used' => $item['quantity_used'] ?? 0, 'quantity_used' => $item['quantity_used'] ?? 0,
'unit_id' => $item['unit_id'] ?? null, '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') return redirect()->route('production-orders.index')
->with('success', $status === 'completed' ? '生產單已完成並入庫' : '草稿已儲存'); ->with('success', '生產單草稿已建立');
} }
/** /**
@@ -204,7 +166,9 @@ class ProductionOrderController extends Controller
if ($productionOrder->product) { if ($productionOrder->product) {
$productionOrder->product->base_unit = $this->inventoryService->getUnits()->where('id', $productionOrder->product->base_unit_id)->first(); $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); $productionOrder->user = $this->coreService->getUser($productionOrder->user_id);
// 手動水和明細資料 // 手動水和明細資料
@@ -214,7 +178,7 @@ class ProductionOrderController extends Controller
// 修正: 移除跨模組關聯 sourcePurchaseOrder.vendor // 修正: 移除跨模組關聯 sourcePurchaseOrder.vendor
$inventories = $this->inventoryService->getInventoriesByIds( $inventories = $this->inventoryService->getInventoriesByIds(
$inventoryIds, $inventoryIds,
['product.baseUnit'] ['product.baseUnit', 'warehouse']
)->keyBy('id'); )->keyBy('id');
// 手動載入 Purchase Orders // 手動載入 Purchase Orders
@@ -238,6 +202,7 @@ class ProductionOrderController extends Controller
return Inertia::render('Production/Show', [ return Inertia::render('Production/Show', [
'productionOrder' => $productionOrder, 'productionOrder' => $productionOrder,
'warehouses' => $this->inventoryService->getAllWarehouses(),
]); ]);
} }
@@ -308,7 +273,9 @@ class ProductionOrderController extends Controller
// 基本水和 // 基本水和
$productionOrder->product = $this->inventoryService->getProduct($productionOrder->product_id); $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; $items = $productionOrder->items;
@@ -346,39 +313,27 @@ class ProductionOrderController extends Controller
$status = $request->input('status', 'draft'); $status = $request->input('status', 'draft');
// 基礎驗證規則 // 基礎驗證規則
$baseRules = [ $rules = [
'product_id' => 'required|exists:products,id', 'product_id' => 'required',
'output_batch_number' => 'required|string|max:50',
'status' => 'required|in:draft,completed',
'remark' => 'nullable|string', '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); $validated = $request->validate($rules);
DB::transaction(function () use ($validated, $request, $status, $productionOrder) { DB::transaction(function () use ($validated, $request, $productionOrder) {
$productionOrder->update([ $productionOrder->update([
'product_id' => $validated['product_id'], 'product_id' => $validated['product_id'],
'warehouse_id' => $validated['warehouse_id'] ?? $productionOrder->warehouse_id, 'warehouse_id' => $validated['warehouse_id'] ?? $productionOrder->warehouse_id,
'output_quantity' => $validated['output_quantity'] ?? 0, '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, 'output_box_count' => $request->output_box_count,
'production_date' => $validated['production_date'] ?? now()->toDateString(), 'production_date' => $request->production_date ?? $productionOrder->production_date,
'expiry_date' => $request->expiry_date, 'expiry_date' => $request->expiry_date ?? $productionOrder->expiry_date,
'status' => $status,
'remark' => $request->remark, 'remark' => $request->remark,
]); ]);
@@ -398,38 +353,8 @@ class ProductionOrderController extends Controller
'quantity_used' => $item['quantity_used'] ?? 0, 'quantity_used' => $item['quantity_used'] ?? 0,
'unit_id' => $item['unit_id'] ?? null, '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') 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) 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) { DB::transaction(function () use ($productionOrder) {
// 紀錄刪除動作 (需在刪除前或使用軟刪除) $productionOrder->items()->delete();
$productionOrder->delete();
activity() activity()
->performedOn($productionOrder) ->performedOn($productionOrder)
->causedBy(auth()->user()) ->causedBy(auth()->user())
->log('deleted'); ->log('deleted');
$productionOrder->items()->delete();
$productionOrder->delete();
}); });
return redirect()->route('production-orders.index')->with('success', '生產單已刪除'); return redirect()->route('production-orders.index')->with('success', '生產單已刪除');

View File

@@ -11,6 +11,14 @@ class ProductionOrder extends Model
{ {
use HasFactory, LogsActivity; 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 = [ protected $fillable = [
'code', 'code',
'product_id', 'product_id',
@@ -25,6 +33,51 @@ class ProductionOrder extends Model
'remark', '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 = [ protected $casts = [
'production_date' => 'date', 'production_date' => 'date',
'expiry_date' => 'date', 'expiry_date' => 'date',

View File

@@ -23,6 +23,12 @@ Route::middleware('auth')->group(function () {
Route::get('/production-orders/{productionOrder}/edit', [ProductionOrderController::class, 'edit'])->name('production-orders.edit'); 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::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 // 生產管理 API

View File

@@ -0,0 +1,49 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Spatie\Permission\Models\Permission;
use Spatie\Permission\Models\Role;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
$guard = 'web';
$permissions = [
'production_orders.approve' => '核准生產工單',
'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();
}
}
};

View File

@@ -0,0 +1,34 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('production_orders', function (Blueprint $table) {
$table->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();
});
}
};

View File

@@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('production_orders', function (Blueprint $table) {
$table->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();
});
}
};

View File

@@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('production_orders', function (Blueprint $table) {
$table->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();
});
}
};

View File

@@ -77,6 +77,8 @@ class PermissionSeeder extends Seeder
'production_orders.create' => '建立', 'production_orders.create' => '建立',
'production_orders.edit' => '編輯', 'production_orders.edit' => '編輯',
'production_orders.delete' => '刪除', 'production_orders.delete' => '刪除',
'production_orders.approve' => '核准',
'production_orders.cancel' => '作廢',
// 配方管理 // 配方管理
'recipes.view' => '檢視', 'recipes.view' => '檢視',

View File

@@ -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 (
<Badge
variant="outline"
className={`${className} ${getStatusStyles(status)} font-bold px-2.5 py-0.5 rounded-full border shadow-none`}
>
{config.label}
</Badge>
);
}

View File

@@ -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 (
<div className="bg-white rounded-lg border shadow-sm p-6">
<h3 className="text-sm font-semibold text-gray-700 mb-6"></h3>
<div className="relative px-4">
{/* 進度條背景 */}
<div className="absolute top-5 left-8 right-8 h-0.5 bg-gray-100" />
{/* 進度條進度 */}
{currentIndex >= 0 && (
<div
className="absolute top-5 left-8 h-0.5 bg-primary transition-all duration-500"
style={{
width: `${(currentIndex / (FLOW_STEPS.length - 1)) * 100}%`,
maxWidth: "calc(100% - 4rem)"
}}
/>
)}
{/* 步驟標記 */}
<div className="relative flex justify-between">
{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 (
<div key={step.key} className="flex flex-col items-center flex-1">
{/* 圓點 */}
<div
className={`w-10 h-10 rounded-full flex items-center justify-center border-2 z-10 transition-all duration-300 ${isRejectedAtThisStep
? "bg-red-500 border-red-500 text-white"
: isCompleted
? "bg-primary border-primary text-white"
: isCurrent
? "bg-white border-primary text-primary ring-4 ring-primary/10 font-bold"
: "bg-white border-gray-200 text-gray-400"
}`}
>
{isCompleted && !isRejectedAtThisStep ? (
<Check className="h-5 w-5" />
) : (
<span className="text-sm">{index + 1}</span>
)}
</div>
{/* 標籤 */}
<div className="mt-3 text-center">
<p
className={`text-xs whitespace-nowrap transition-colors ${isRejectedAtThisStep
? "text-red-600 font-bold"
: isCompleted || isCurrent
? "text-gray-900 font-bold"
: "text-gray-400"
}`}
>
{isRejectedAtThisStep ? "已作廢" : step.label}
</p>
</div>
</div>
);
})}
</div>
</div>
</div>
);
}

View File

@@ -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<number | null>(null);
const [batchNumber, setBatchNumber] = React.useState<string>("");
const [expiryDate, setExpiryDate] = React.useState<string>("");
// 當開啟時,嘗試產生成品批號 (若有資訊)
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 (
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-primary-main">
<WarehouseIcon className="h-5 w-5" />
</DialogTitle>
</DialogHeader>
<div className="py-6 space-y-6">
<div className="space-y-1">
<Label className="text-xs font-medium text-grey-2 flex items-center gap-1">
<WarehouseIcon className="h-3 w-3" />
*
</Label>
<SearchableSelect
options={warehouses.map(w => ({ value: w.id.toString(), label: w.name }))}
value={selectedId?.toString() || ""}
onValueChange={(val) => setSelectedId(parseInt(val))}
placeholder="請選擇倉庫..."
className="w-full"
/>
</div>
<div className="space-y-1">
<Label className="text-xs font-medium text-grey-2 flex items-center gap-1">
<Tag className="h-3 w-3" />
*
</Label>
<Input
value={batchNumber}
onChange={(e) => setBatchNumber(e.target.value)}
placeholder="輸入成品批號"
className="h-9 font-mono"
/>
</div>
<div className="space-y-1">
<Label className="text-xs font-medium text-grey-2 flex items-center gap-1">
<CalendarIcon className="h-3 w-3" />
()
</Label>
<Input
type="date"
value={expiryDate}
onChange={(e) => setExpiryDate(e.target.value)}
className="h-9"
/>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={onClose}
disabled={processing}
className="gap-2 button-outlined-error"
>
<X className="h-4 w-4" />
</Button>
<Button
onClick={handleConfirm}
disabled={!selectedId || !batchNumber || processing}
className="gap-2 button-filled-primary"
>
<CheckCircle2 className="h-4 w-4" />
{processing ? "處理中..." : "確認完工入庫"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -1,5 +1,5 @@
import * as React from "react"; import * as React from "react";
import { Check, ChevronsUpDown } from "lucide-react"; import { Check, ChevronsUpDown, X } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { import {
@@ -36,6 +36,8 @@ interface SearchableSelectProps {
searchThreshold?: number; searchThreshold?: number;
/** 強制控制是否顯示搜尋框。若設定此值,則忽略 searchThreshold */ /** 強制控制是否顯示搜尋框。若設定此值,則忽略 searchThreshold */
showSearch?: boolean; showSearch?: boolean;
/** 是否可清除選取 */
isClearable?: boolean;
} }
export function SearchableSelect({ export function SearchableSelect({
@@ -49,6 +51,7 @@ export function SearchableSelect({
className, className,
searchThreshold = 10, searchThreshold = 10,
showSearch, showSearch,
isClearable = false,
}: SearchableSelectProps) { }: SearchableSelectProps) {
const [open, setOpen] = React.useState(false); const [open, setOpen] = React.useState(false);
@@ -86,7 +89,18 @@ export function SearchableSelect({
<span className="truncate"> <span className="truncate">
{selectedOption ? selectedOption.label : placeholder} {selectedOption ? selectedOption.label : placeholder}
</span> </span>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50 text-grey-2" /> <div className="flex items-center gap-1 shrink-0">
{isClearable && value && !disabled && (
<X
className="h-4 w-4 text-grey-3 hover:text-grey-1 transition-colors pointer-events-auto cursor-pointer"
onClick={(e) => {
e.stopPropagation();
onValueChange("");
}}
/>
)}
<ChevronsUpDown className="h-4 w-4 opacity-50 text-grey-2" />
</div>
</button> </button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent <PopoverContent

View File

@@ -4,17 +4,17 @@
*/ */
import { useState, useEffect } from "react"; 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 { formatQuantity } from "@/lib/utils";
import { Button } from "@/Components/ui/button"; import { Button } from "@/Components/ui/button";
import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout"; import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout";
import { Head, router, useForm } from "@inertiajs/react"; import { router, useForm, Head, Link } from "@inertiajs/react";
import { toast } from "sonner"; import { toast } from "sonner";
import { getBreadcrumbs } from "@/utils/breadcrumb"; import { getBreadcrumbs } from "@/utils/breadcrumb";
import { SearchableSelect } from "@/Components/ui/searchable-select"; import { SearchableSelect } from "@/Components/ui/searchable-select";
import { Input } from "@/Components/ui/input"; import { Input } from "@/Components/ui/input";
import { Label } from "@/Components/ui/label"; import { Label } from "@/Components/ui/label";
import { Textarea } from "@/Components/ui/textarea"; import { Textarea } from "@/Components/ui/textarea";
import { Link } from "@inertiajs/react";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/Components/ui/table"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/Components/ui/table";
interface Product { interface Product {
@@ -84,7 +84,7 @@ interface Props {
units: Unit[]; units: Unit[];
} }
export default function ProductionCreate({ products, warehouses }: Props) { export default function Create({ products, warehouses, units }: Props) {
const [selectedWarehouse, setSelectedWarehouse] = useState<string>(""); // 產出倉庫 const [selectedWarehouse, setSelectedWarehouse] = useState<string>(""); // 產出倉庫
// 快取對照表product_id -> inventories across warehouses // 快取對照表product_id -> inventories across warehouses
const [productInventoryMap, setProductInventoryMap] = useState<Record<string, InventoryOption[]>>({}); const [productInventoryMap, setProductInventoryMap] = useState<Record<string, InventoryOption[]>>({});
@@ -100,11 +100,7 @@ export default function ProductionCreate({ products, warehouses }: Props) {
product_id: "", product_id: "",
warehouse_id: "", warehouse_id: "",
output_quantity: "", output_quantity: "",
output_batch_number: "", // 移除成品批號、生產日期、有效日期,這些欄位改在完工時處理或自動記錄
// 移除 Box Count UI
// 移除相關邏輯
production_date: new Date().toISOString().split('T')[0],
expiry_date: "",
remark: "", remark: "",
items: [] as { inventory_id: number; quantity_used: number; unit_id: number | null }[], 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') { if (field === 'ui_warehouse_id') {
item.inventory_id = ""; item.inventory_id = "";
item.quantity_used = ""; // 不重置數量
item.unit_id = ""; // item.quantity_used = "";
item.ui_input_quantity = ""; // item.ui_input_quantity = "";
item.ui_selected_unit = "base"; // item.ui_selected_unit = "base";
// 清除某些 cache // 清除某些 cache
delete item.ui_batch_number; delete item.ui_batch_number;
delete item.ui_available_qty; delete item.ui_available_qty;
@@ -215,6 +212,11 @@ export default function ProductionCreate({ products, warehouses }: Props) {
// 預設單位 // 預設單位
item.ui_selected_unit = 'base'; item.ui_selected_unit = 'base';
item.unit_id = String(inv.base_unit_id || ''); 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(() => { useEffect(() => {
if (!data.product_id) return; 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. 自動載入配方列表 // 2. 自動載入配方列表
const fetchRecipes = async () => { const fetchRecipes = async () => {
try { try {
@@ -362,9 +343,7 @@ export default function ProductionCreate({ products, warehouses }: Props) {
const missingFields = []; const missingFields = [];
if (!data.product_id) missingFields.push('成品商品'); if (!data.product_id) missingFields.push('成品商品');
if (!data.output_quantity) missingFields.push('生產數量'); if (!data.output_quantity) missingFields.push('生產數量');
if (!data.output_batch_number) missingFields.push('成品批號'); if (!selectedWarehouse) missingFields.push('預計入庫倉庫');
if (!data.production_date) missingFields.push('生產日期');
if (!selectedWarehouse) missingFields.push('入庫倉庫');
if (bomItems.length === 0) missingFields.push('原物料明細'); if (bomItems.length === 0) missingFields.push('原物料明細');
if (missingFields.length > 0) { if (missingFields.length > 0) {
@@ -387,6 +366,7 @@ export default function ProductionCreate({ products, warehouses }: Props) {
// 使用 router.post 提交完整資料 // 使用 router.post 提交完整資料
router.post(route('production-orders.store'), { router.post(route('production-orders.store'), {
...data, ...data,
warehouse_id: selectedWarehouse ? parseInt(selectedWarehouse) : null,
items: formattedItems, items: formattedItems,
status: status, status: status,
}, { }, {
@@ -430,25 +410,14 @@ export default function ProductionCreate({ products, warehouses }: Props) {
</p> </p>
</div> </div>
<div className="flex gap-2"> <Button
<Button onClick={() => submit('draft')}
onClick={() => submit('draft')} disabled={processing}
disabled={processing} className="gap-2 button-filled-primary"
variant="outline" >
className="button-outlined-primary" <Save className="h-4 w-4" />
> (稿)
<Save className="mr-2 h-4 w-4" /> </Button>
稿
</Button>
<Button
onClick={() => submit('completed')}
disabled={processing}
className="button-filled-primary"
>
<Factory className="mr-2 h-4 w-4" />
</Button>
</div>
</div> </div>
</div> </div>
@@ -499,56 +468,16 @@ export default function ProductionCreate({ products, warehouses }: Props) {
<Input <Input
type="number" type="number"
step="any" step="any"
value={data.output_quantity} value={Number(data.output_quantity) === 0 ? '' : formatQuantity(data.output_quantity)}
onChange={(e) => setData('output_quantity', e.target.value)} onChange={(e) => setData('output_quantity', e.target.value)}
placeholder="例如: 50" placeholder="例如: 50"
className="h-9" className="h-9 font-mono"
/> />
{errors.output_quantity && <p className="text-red-500 text-xs mt-1">{errors.output_quantity}</p>} {errors.output_quantity && <p className="text-red-500 text-xs mt-1">{errors.output_quantity}</p>}
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<Label className="text-xs font-medium text-grey-2"> *</Label> <Label className="text-xs font-medium text-grey-2"> *</Label>
<Input
value={data.output_batch_number}
onChange={(e) => setData('output_batch_number', e.target.value)}
placeholder="選擇商品後自動產生"
className="h-9 font-mono"
/>
{errors.output_batch_number && <p className="text-red-500 text-xs mt-1">{errors.output_batch_number}</p>}
</div>
<div className="space-y-1">
<Label className="text-xs font-medium text-grey-2"> *</Label>
<div className="relative">
<Calendar className="absolute left-2.5 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400 pointer-events-none" />
<Input
type="date"
value={data.production_date}
onChange={(e) => setData('production_date', e.target.value)}
className="h-9 pl-9"
/>
</div>
{errors.production_date && <p className="text-red-500 text-xs mt-1">{errors.production_date}</p>}
</div>
<div className="space-y-1">
<Label className="text-xs font-medium text-grey-2"></Label>
<div className="relative">
<Calendar className="absolute left-2.5 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400 pointer-events-none" />
<Input
type="date"
value={data.expiry_date}
onChange={(e) => setData('expiry_date', e.target.value)}
className="h-9 pl-9"
/>
</div>
</div>
<div className="space-y-1">
<Label className="text-xs font-medium text-grey-2"> *</Label>
<SearchableSelect <SearchableSelect
value={selectedWarehouse} value={selectedWarehouse}
onValueChange={setSelectedWarehouse} onValueChange={setSelectedWarehouse}
@@ -602,12 +531,12 @@ export default function ProductionCreate({ products, warehouses }: Props) {
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow className="bg-gray-50/50"> <TableRow className="bg-gray-50/50">
<TableHead className="w-[20%]"> <span className="text-red-500">*</span></TableHead> <TableHead className="w-[18%]"> <span className="text-red-500">*</span></TableHead>
<TableHead className="w-[20%]"> <span className="text-red-500">*</span></TableHead> <TableHead className="w-[15%]"> <span className="text-red-500">*</span></TableHead>
<TableHead className="w-[25%]"> <span className="text-red-500">*</span></TableHead> <TableHead className="w-[30%]"> <span className="text-red-500">*</span></TableHead>
<TableHead className="w-[15%]"> <span className="text-red-500">*</span></TableHead> <TableHead className="w-[12%]"> <span className="text-red-500">*</span></TableHead>
<TableHead className="w-[15%]"></TableHead> <TableHead className="w-[10%]"></TableHead>
<TableHead className="w-[5%]"></TableHead> <TableHead className="w-[10%]"></TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
@@ -618,7 +547,7 @@ export default function ProductionCreate({ products, warehouses }: Props) {
value: String(p.id) value: String(p.id)
})); }));
// 2. 來源倉庫選項 (根據商品庫存過濾) // 2. 來源倉庫選項 (根據商品庫存過濾)
const currentInventories = productInventoryMap[item.ui_product_id] || []; const currentInventories = productInventoryMap[item.ui_product_id] || [];
const filteredWarehouseOptions = Array.from(new Map( const filteredWarehouseOptions = Array.from(new Map(
currentInventories.map((inv: InventoryOption) => [inv.warehouse_id, { label: inv.warehouse_name, value: String(inv.warehouse_id) }]) 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 ? filteredWarehouseOptions
: (item.ui_product_id && !loadingProducts[item.ui_product_id] ? [] : []); : (item.ui_product_id && !loadingProducts[item.ui_product_id] ? [] : []);
// 3. 批號選項 (根據商品與倉庫過濾) // 3. 批號選項 (利用 sublabel 顯示詳細資訊,保持選中後簡潔)
const batchOptions = currentInventories const batchOptions = currentInventories
.filter((inv: InventoryOption) => String(inv.warehouse_id) === item.ui_warehouse_id) .filter((inv: InventoryOption) => String(inv.warehouse_id) === item.ui_warehouse_id)
.map((inv: InventoryOption) => ({ .map((inv: InventoryOption) => ({
label: `${inv.batch_number} / ${inv.expiry_date || '無效期'} / ${inv.quantity}`, label: inv.batch_number,
value: String(inv.id) value: String(inv.id),
sublabel: `(存:${inv.quantity} | 效:${inv.expiry_date || '無'})`
})); }));
return ( return (
@@ -678,11 +608,16 @@ export default function ProductionCreate({ products, warehouses }: Props) {
/> />
{item.inventory_id && (() => { {item.inventory_id && (() => {
const selectedInv = currentInventories.find((i: InventoryOption) => String(i.id) === item.inventory_id); const selectedInv = currentInventories.find((i: InventoryOption) => String(i.id) === item.inventory_id);
if (selectedInv) return ( if (selectedInv) {
<div className="text-xs text-gray-500 mt-1"> const isInsufficient = selectedInv.quantity < parseFloat(item.ui_input_quantity || '0');
: {selectedInv.expiry_date || '無'} | : {selectedInv.quantity} return (
</div> <div className={`text-xs mt-1 ${isInsufficient ? 'text-red-500 font-bold animate-pulse' : 'text-gray-500'}`}>
); : {selectedInv.expiry_date || '無'} |
: {formatQuantity(selectedInv.quantity)}
{isInsufficient && ' (庫存不足!)'}
</div>
);
}
return null; return null;
})()} })()}
</TableCell> </TableCell>
@@ -692,7 +627,7 @@ export default function ProductionCreate({ products, warehouses }: Props) {
<Input <Input
type="number" type="number"
step="any" step="any"
value={item.ui_input_quantity} value={formatQuantity(item.ui_input_quantity)}
onChange={(e) => updateBomItem(index, 'ui_input_quantity', e.target.value)} onChange={(e) => updateBomItem(index, 'ui_input_quantity', e.target.value)}
placeholder="0" placeholder="0"
className="h-9 text-right" className="h-9 text-right"
@@ -700,20 +635,22 @@ export default function ProductionCreate({ products, warehouses }: Props) {
/> />
</TableCell> </TableCell>
{/* 4. 選擇單位 */} {/* 4. 單位 */}
<TableCell className="align-top pt-3"> <TableCell className="align-top">
<span className="text-sm">{item.ui_base_unit_name}</span> <div className="h-9 flex items-center px-1 text-sm text-gray-600 font-medium">
{item.ui_base_unit_name || '-'}
</div>
</TableCell> </TableCell>
<TableCell className="align-top"> <TableCell className="align-top">
<Button <Button
type="button" type="button"
variant="ghost" variant="outline"
size="sm" size="sm"
onClick={() => removeBomItem(index)} onClick={() => removeBomItem(index)}
className="text-red-500 hover:text-red-700 hover:bg-red-50 p-2" className="button-outlined-error"
title="刪除"
> >
<Trash2 className="h-4 w-4" /> <Trash2 className="h-4 w-4" />
</Button> </Button>

View File

@@ -4,10 +4,11 @@
*/ */
import { useState, useEffect } from "react"; 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 { Button } from "@/Components/ui/button";
import { formatQuantity } from "@/lib/utils";
import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout"; 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 toast, { Toaster } from 'react-hot-toast';
import { getBreadcrumbs } from "@/utils/breadcrumb"; import { getBreadcrumbs } from "@/utils/breadcrumb";
import { SearchableSelect } from "@/Components/ui/searchable-select"; import { SearchableSelect } from "@/Components/ui/searchable-select";
@@ -107,10 +108,6 @@ interface ProductionOrder {
product_id: number; product_id: number;
warehouse_id: number | null; warehouse_id: number | null;
output_quantity: number; output_quantity: number;
output_batch_number: string;
output_box_count: string | null;
production_date: string;
expiry_date: string | null;
remark: string | null; remark: string | null;
status: string; status: string;
items: ProductionOrderItem[]; items: ProductionOrderItem[];
@@ -126,18 +123,9 @@ interface Props {
} }
export default function ProductionEdit({ productionOrder, products, warehouses }: 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<string>( const [selectedWarehouse, setSelectedWarehouse] = useState<string>(
productionOrder.warehouse_id ? String(productionOrder.warehouse_id) : "" productionOrder.warehouse_id ? String(productionOrder.warehouse_id) : ""
); // 產出倉庫 ); // 預計入庫倉庫
// 快取對照表product_id -> inventories // 快取對照表product_id -> inventories
const [productInventoryMap, setProductInventoryMap] = useState<Record<string, InventoryOption[]>>({}); const [productInventoryMap, setProductInventoryMap] = useState<Record<string, InventoryOption[]>>({});
@@ -169,7 +157,7 @@ export default function ProductionEdit({ productionOrder, products, warehouses }
// UI Initial State (復原) // UI Initial State (復原)
ui_warehouse_id: item.inventory?.warehouse_id ? String(item.inventory.warehouse_id) : "", ui_warehouse_id: item.inventory?.warehouse_id ? String(item.inventory.warehouse_id) : "",
ui_product_id: item.inventory ? String(item.inventory.product_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_selected_unit: 'base',
// UI 輔助 // UI 輔助
@@ -183,11 +171,7 @@ export default function ProductionEdit({ productionOrder, products, warehouses }
const { data, setData, processing, errors } = useForm({ const { data, setData, processing, errors } = useForm({
product_id: String(productionOrder.product_id), product_id: String(productionOrder.product_id),
warehouse_id: productionOrder.warehouse_id ? String(productionOrder.warehouse_id) : "", warehouse_id: productionOrder.warehouse_id ? String(productionOrder.warehouse_id) : "",
output_quantity: productionOrder.output_quantity ? String(productionOrder.output_quantity) : "", output_quantity: productionOrder.output_quantity ? formatQuantity(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),
remark: productionOrder.remark || "", remark: productionOrder.remark || "",
items: [] as { inventory_id: number; quantity_used: number; unit_id: number | null }[], 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) { if (inv) {
return { return {
...item, ...item,
ui_warehouse_id: String(inv.warehouse_id), // 重要:還原倉庫 ID ui_warehouse_id: String(inv.warehouse_id),
ui_product_name: inv.product_name, ui_product_name: inv.product_name,
ui_batch_number: inv.batch_number, ui_batch_number: inv.batch_number,
ui_available_qty: inv.quantity, ui_available_qty: inv.quantity,
@@ -255,7 +239,7 @@ export default function ProductionEdit({ productionOrder, products, warehouses }
const updated = [...bomItems]; const updated = [...bomItems];
const item = { ...updated[index], [field]: value }; const item = { ...updated[index], [field]: value };
// 0. 當選擇商品變更時 (第一層) // 0. 當選擇商品變更時
if (field === 'ui_product_id') { if (field === 'ui_product_id') {
item.ui_warehouse_id = ""; item.ui_warehouse_id = "";
item.inventory_id = ""; item.inventory_id = "";
@@ -263,7 +247,6 @@ export default function ProductionEdit({ productionOrder, products, warehouses }
item.unit_id = ""; item.unit_id = "";
item.ui_input_quantity = ""; item.ui_input_quantity = "";
item.ui_selected_unit = "base"; item.ui_selected_unit = "base";
// 保留基本資訊
if (value) { if (value) {
const prod = products.find(p => String(p.id) === value); const prod = products.find(p => String(p.id) === value);
if (prod) { if (prod) {
@@ -274,16 +257,12 @@ export default function ProductionEdit({ productionOrder, products, warehouses }
} }
} }
// 1. 當選擇來源倉庫變更時 (第二層) // 1. 當選擇來源倉庫變更時
if (field === 'ui_warehouse_id') { if (field === 'ui_warehouse_id') {
item.inventory_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) { if (field === 'inventory_id' && value) {
const currentOptions = productInventoryMap[item.ui_product_id] || []; const currentOptions = productInventoryMap[item.ui_product_id] || [];
const inv = currentOptions.find(i => String(i.id) === value); 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.ui_selected_unit = 'base';
item.unit_id = String(inv.base_unit_id || ''); 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]); }, [bomItems]);
// 提交表單(完成模式) // 提交表單
// 提交表單(完成模式)
// 提交表單(完成模式)
const submit = (status: 'draft' | 'completed') => { const submit = (status: 'draft' | 'completed') => {
// 驗證(簡單前端驗證)
if (status === 'completed') { if (status === 'completed') {
const missingFields = []; const missingFields = [];
if (!data.product_id) missingFields.push('成品商品'); if (!data.product_id) missingFields.push('成品商品');
if (!data.output_quantity) missingFields.push('生產數量'); if (!data.output_quantity) missingFields.push('生產數量');
if (!data.output_batch_number) missingFields.push('成品批號'); if (!selectedWarehouse) missingFields.push('預計入庫倉庫');
if (!data.production_date) missingFields.push('生產日期');
if (!selectedWarehouse) missingFields.push('入庫倉庫');
if (bomItems.length === 0) missingFields.push('原物料明細'); if (bomItems.length === 0) missingFields.push('原物料明細');
if (missingFields.length > 0) { if (missingFields.length > 0) {
@@ -384,24 +362,22 @@ export default function ProductionEdit({ productionOrder, products, warehouses }
const handleSubmit = (e: React.FormEvent) => { const handleSubmit = (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
submit('completed'); submit('draft');
}; };
return ( return (
<AuthenticatedLayout breadcrumbs={getBreadcrumbs("productionOrdersCreate")}> <AuthenticatedLayout breadcrumbs={getBreadcrumbs("productionOrdersCreate")}>
<Head title={`編輯生產單 - ${productionOrder.code}`} /> <Head title={`編輯生產單 - ${productionOrder.code}`} />
<Toaster position="top-right" /> <Toaster position="top-right" />
<div className="container mx-auto p-6 max-w-7xl"> <div className="container mx-auto p-6 max-w-7xl animate-in fade-in duration-500">
<div className="mb-6"> <div className="mb-6">
<Link href={route('production-orders.index')}> <Link href={route('production-orders.index')}>
<Button <Button
variant="outline" variant="outline"
className="gap-2 button-outlined-primary mb-6" className="gap-2 button-outlined-primary mb-4"
> >
<ArrowLeft className="h-4 w-4" /> <ArrowLeft className="h-4 w-4" />
</Button> </Button>
</Link> </Link>
@@ -409,38 +385,27 @@ export default function ProductionEdit({ productionOrder, products, warehouses }
<div> <div>
<h1 className="text-2xl font-bold text-grey-0 flex items-center gap-2"> <h1 className="text-2xl font-bold text-grey-0 flex items-center gap-2">
<Factory className="h-6 w-6 text-primary-main" /> <Factory className="h-6 w-6 text-primary-main" />
{productionOrder.code}
</h1> </h1>
<p className="text-gray-500 mt-1"> <p className="text-gray-500 mt-1">
稿
</p> </p>
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
<Button <Button
onClick={() => submit('draft')} onClick={() => submit('draft')}
disabled={processing} disabled={processing}
variant="outline" className="gap-2 button-filled-primary"
className="button-outlined-primary"
> >
<Save className="mr-2 h-4 w-4" /> <Save className="h-4 w-4" />
稿 (稿)
</Button>
<Button
onClick={() => submit('completed')}
disabled={processing}
className="button-filled-primary"
>
<Factory className="mr-2 h-4 w-4" />
</Button> </Button>
</div> </div>
</div> </div>
</div> </div>
<form onSubmit={handleSubmit} className="space-y-6"> <form onSubmit={handleSubmit} className="space-y-6">
{/* 成品資訊 */}
<div className="bg-white p-6 rounded-lg shadow-sm border border-gray-200 mb-6"> <div className="bg-white p-6 rounded-lg shadow-sm border border-gray-200 mb-6">
<h2 className="text-lg font-semibold mb-4"></h2> <h2 className="text-lg font-semibold mb-4"></h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-1"> <div className="space-y-1">
@@ -463,64 +428,16 @@ export default function ProductionEdit({ productionOrder, products, warehouses }
<Input <Input
type="number" type="number"
step="any" step="any"
value={data.output_quantity} value={Number(data.output_quantity) === 0 ? '' : formatQuantity(data.output_quantity)}
onChange={(e) => setData('output_quantity', e.target.value)} onChange={(e) => setData('output_quantity', e.target.value)}
placeholder="例如: 50" placeholder="例如: 50"
className="h-9" className="h-9 font-mono"
/> />
{errors.output_quantity && <p className="text-red-500 text-xs mt-1">{errors.output_quantity}</p>} {errors.output_quantity && <p className="text-red-500 text-xs mt-1">{errors.output_quantity}</p>}
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<Label className="text-xs font-medium text-grey-2"> *</Label> <Label className="text-xs font-medium text-grey-2"> *</Label>
<Input
value={data.output_batch_number}
onChange={(e) => setData('output_batch_number', e.target.value)}
placeholder="例如: AB-TW-20260122-01"
className="h-9 font-mono"
/>
{errors.output_batch_number && <p className="text-red-500 text-xs mt-1">{errors.output_batch_number}</p>}
</div>
<div className="space-y-1">
<Label className="text-xs font-medium text-grey-2"></Label>
<Input
value={data.output_box_count}
onChange={(e) => setData('output_box_count', e.target.value)}
placeholder="例如: 10"
className="h-9"
/>
</div>
<div className="space-y-1">
<Label className="text-xs font-medium text-grey-2"> *</Label>
<div className="relative">
<Calendar className="absolute left-2.5 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400 pointer-events-none" />
<Input
type="date"
value={data.production_date}
onChange={(e) => setData('production_date', e.target.value)}
className="h-9 pl-9"
/>
</div>
{errors.production_date && <p className="text-red-500 text-xs mt-1">{errors.production_date}</p>}
</div>
<div className="space-y-1">
<Label className="text-xs font-medium text-grey-2"></Label>
<div className="relative">
<Calendar className="absolute left-2.5 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400 pointer-events-none" />
<Input
type="date"
value={data.expiry_date}
onChange={(e) => setData('expiry_date', e.target.value)}
className="h-9 pl-9"
/>
</div>
</div>
<div className="space-y-1">
<Label className="text-xs font-medium text-grey-2"> *</Label>
<SearchableSelect <SearchableSelect
value={selectedWarehouse} value={selectedWarehouse}
onValueChange={setSelectedWarehouse} onValueChange={setSelectedWarehouse}
@@ -547,7 +464,6 @@ export default function ProductionEdit({ productionOrder, products, warehouses }
</div> </div>
</div> </div>
{/* BOM 原物料明細 */}
<div className="bg-white p-6 rounded-lg shadow-sm border border-gray-200 mb-6"> <div className="bg-white p-6 rounded-lg shadow-sm border border-gray-200 mb-6">
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold">使 (BOM)</h2> <h2 className="text-lg font-semibold">使 (BOM)</h2>
@@ -574,23 +490,21 @@ export default function ProductionEdit({ productionOrder, products, warehouses }
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow className="bg-gray-50/50"> <TableRow className="bg-gray-50/50">
<TableHead className="w-[20%]"> <span className="text-red-500">*</span></TableHead> <TableHead className="w-[18%]"> <span className="text-red-500">*</span></TableHead>
<TableHead className="w-[20%]"> <span className="text-red-500">*</span></TableHead> <TableHead className="w-[15%]"> <span className="text-red-500">*</span></TableHead>
<TableHead className="w-[25%]"> <span className="text-red-500">*</span></TableHead> <TableHead className="w-[30%]"> <span className="text-red-500">*</span></TableHead>
<TableHead className="w-[15%]"> <span className="text-red-500">*</span></TableHead> <TableHead className="w-[15%]"> <span className="text-red-500">*</span></TableHead>
<TableHead className="w-[15%]"></TableHead> <TableHead className="w-[12%]"></TableHead>
<TableHead className="w-[5%]"></TableHead> <TableHead className="w-[10%]"></TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{bomItems.map((item, index) => { {bomItems.map((item, index) => {
// 1. 商品選項
const productOptions = products.map(p => ({ const productOptions = products.map(p => ({
label: `${p.name} (${p.code})`, label: `${p.name} (${p.code})`,
value: String(p.id) value: String(p.id)
})); }));
// 2. 來源倉庫選項 (根據商品库庫存過濾)
const currentInventories = productInventoryMap[item.ui_product_id] || []; const currentInventories = productInventoryMap[item.ui_product_id] || [];
const filteredWarehouseOptions = Array.from(new Map( const filteredWarehouseOptions = Array.from(new Map(
currentInventories.map((inv: InventoryOption) => [inv.warehouse_id, { label: inv.warehouse_name, value: String(inv.warehouse_id) }]) 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 ? filteredWarehouseOptions
: (item.ui_product_id && !loadingProducts[item.ui_product_id] ? [] : []); : (item.ui_product_id && !loadingProducts[item.ui_product_id] ? [] : []);
// 備案 (初始載入時)
const displayWarehouseOptions = uniqueWarehouseOptions.length > 0 const displayWarehouseOptions = uniqueWarehouseOptions.length > 0
? uniqueWarehouseOptions ? uniqueWarehouseOptions
: (item.ui_warehouse_id ? [{ label: "載入中...", value: item.ui_warehouse_id }] : []); : (item.ui_warehouse_id ? [{ label: "載入中...", value: item.ui_warehouse_id }] : []);
// 3. 批號選項 (根據商品與倉庫過濾)
const batchOptions = currentInventories const batchOptions = currentInventories
.filter((inv: InventoryOption) => String(inv.warehouse_id) === item.ui_warehouse_id) .filter((inv: InventoryOption) => String(inv.warehouse_id) === item.ui_warehouse_id)
.map((inv: InventoryOption) => ({ .map((inv: InventoryOption) => ({
label: `${inv.batch_number} / ${inv.expiry_date || '無效期'} / ${inv.quantity}`, label: inv.batch_number,
value: String(inv.id) 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 }] : []); const displayBatchOptions = batchOptions.length > 0 ? batchOptions : (item.inventory_id && item.ui_batch_number ? [{ label: item.ui_batch_number, value: item.inventory_id }] : []);
return ( return (
<TableRow key={index}> <TableRow key={index}>
{/* 1. 選擇商品 */}
<TableCell className="align-top"> <TableCell className="align-top">
<SearchableSelect <SearchableSelect
value={item.ui_product_id} value={item.ui_product_id}
@@ -630,7 +540,6 @@ export default function ProductionEdit({ productionOrder, products, warehouses }
/> />
</TableCell> </TableCell>
{/* 2. 選擇來源倉庫 */}
<TableCell className="align-top"> <TableCell className="align-top">
<SearchableSelect <SearchableSelect
value={item.ui_warehouse_id} value={item.ui_warehouse_id}
@@ -646,7 +555,6 @@ export default function ProductionEdit({ productionOrder, products, warehouses }
/> />
</TableCell> </TableCell>
{/* 3. 選擇批號 */}
<TableCell className="align-top"> <TableCell className="align-top">
<SearchableSelect <SearchableSelect
value={item.inventory_id} value={item.inventory_id}
@@ -658,21 +566,25 @@ export default function ProductionEdit({ productionOrder, products, warehouses }
/> />
{item.inventory_id && (() => { {item.inventory_id && (() => {
const selectedInv = currentInventories.find((i: InventoryOption) => String(i.id) === item.inventory_id); const selectedInv = currentInventories.find((i: InventoryOption) => String(i.id) === item.inventory_id);
if (selectedInv) return ( if (selectedInv) {
<div className="text-xs text-gray-500 mt-1"> const isInsufficient = selectedInv.quantity < parseFloat(item.ui_input_quantity || '0');
: {selectedInv.expiry_date || '無'} | : {selectedInv.quantity} return (
</div> <div className={`text-xs mt-1 ${isInsufficient ? 'text-red-500 font-bold animate-pulse' : 'text-gray-500'}`}>
); : {selectedInv.expiry_date || '無'} |
: {formatQuantity(selectedInv.quantity)}
{isInsufficient && ' (庫存不足!)'}
</div>
);
}
return null; return null;
})()} })()}
</TableCell> </TableCell>
{/* 3. 輸入數量 */}
<TableCell className="align-top"> <TableCell className="align-top">
<Input <Input
type="number" type="number"
step="any" step="any"
value={item.ui_input_quantity} value={formatQuantity(item.ui_input_quantity)}
onChange={(e) => updateBomItem(index, 'ui_input_quantity', e.target.value)} onChange={(e) => updateBomItem(index, 'ui_input_quantity', e.target.value)}
placeholder="0" placeholder="0"
className="h-9 text-right" className="h-9 text-right"
@@ -680,20 +592,20 @@ export default function ProductionEdit({ productionOrder, products, warehouses }
/> />
</TableCell> </TableCell>
{/* 4. 選擇單位 */} <TableCell className="align-top">
<TableCell className="align-top pt-3"> <div className="h-9 flex items-center px-1 text-sm text-gray-600 font-medium">
<span className="text-sm">{item.ui_base_unit_name}</span> {item.ui_base_unit_name || '-'}
</div>
</TableCell> </TableCell>
<TableCell className="align-top"> <TableCell className="align-top">
<Button <Button
type="button" type="button"
variant="ghost" variant="outline"
size="sm" size="sm"
onClick={() => removeBomItem(index)} onClick={() => removeBomItem(index)}
className="text-red-500 hover:text-red-700 hover:bg-red-50 p-2" className="button-outlined-error"
title="刪除"
> >
<Trash2 className="h-4 w-4" /> <Trash2 className="h-4 w-4" />
</Button> </Button>

View File

@@ -4,6 +4,7 @@
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { Plus, Factory, Search, Eye, Pencil, Trash2 } from 'lucide-react'; import { Plus, Factory, Search, Eye, Pencil, Trash2 } from 'lucide-react';
import { formatQuantity } from "@/lib/utils";
import { Button } from "@/Components/ui/button"; import { Button } from "@/Components/ui/button";
import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout"; import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout";
import { Head, router, Link } from "@inertiajs/react"; import { Head, router, Link } from "@inertiajs/react";
@@ -20,8 +21,9 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/Components/ui/select"; } from "@/Components/ui/select";
import { Badge } from "@/Components/ui/badge";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/Components/ui/table"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/Components/ui/table";
import ProductionOrderStatusBadge from "@/Components/ProductionOrder/ProductionOrderStatusBadge";
import { formatDate } from "@/lib/date";
interface ProductionOrder { interface ProductionOrder {
id: number; id: number;
@@ -32,7 +34,7 @@ interface ProductionOrder {
output_batch_number: string; output_batch_number: string;
output_quantity: number; output_quantity: number;
production_date: string; production_date: string;
status: 'draft' | 'completed' | 'cancelled'; status: 'draft' | 'pending' | 'approved' | 'in_progress' | 'completed' | 'cancelled';
created_at: string; created_at: string;
} }
@@ -51,11 +53,15 @@ interface Props {
}; };
} }
const statusConfig: Record<string, { label: string; variant: "default" | "secondary" | "destructive" | "outline" }> = { const statusOptions = [
draft: { label: "草稿", variant: "secondary" }, { value: 'all', label: '全部狀態' },
completed: { label: "已完成", variant: "default" }, { value: 'draft', label: '草稿' },
cancelled: { label: "已取消", variant: "destructive" }, { value: 'pending', label: '審核中' },
}; { value: 'approved', label: '已核准' },
{ value: 'in_progress', label: '製作中' },
{ value: 'completed', label: '已完成' },
{ value: 'cancelled', label: '已取消' },
];
export default function ProductionIndex({ productionOrders, filters }: Props) { export default function ProductionIndex({ productionOrders, filters }: Props) {
const [search, setSearch] = useState(filters.search || ""); const [search, setSearch] = useState(filters.search || "");
@@ -154,10 +160,11 @@ export default function ProductionIndex({ productionOrders, filters }: Props) {
<SelectValue placeholder="選擇狀態" /> <SelectValue placeholder="選擇狀態" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="all"></SelectItem> {statusOptions.map(opt => (
<SelectItem value="draft">稿</SelectItem> <SelectItem key={opt.value} value={opt.value}>
<SelectItem value="completed"></SelectItem> {opt.label}
<SelectItem value="cancelled"></SelectItem> </SelectItem>
))}
</SelectContent> </SelectContent>
</Select> </Select>
@@ -230,18 +237,16 @@ export default function ProductionIndex({ productionOrders, filters }: Props) {
</code> </code>
</TableCell> </TableCell>
<TableCell className="text-right font-medium"> <TableCell className="text-right font-medium">
{order.output_quantity.toLocaleString()} {formatQuantity(order.output_quantity)}
</TableCell> </TableCell>
<TableCell className="text-gray-600"> <TableCell className="text-gray-600">
{order.warehouse?.name || '-'} {order.warehouse?.name || '-'}
</TableCell> </TableCell>
<TableCell className="text-gray-600"> <TableCell className="text-gray-600">
{order.production_date} {formatDate(order.production_date)}
</TableCell> </TableCell>
<TableCell className="text-center"> <TableCell className="text-center">
<Badge variant={statusConfig[order.status]?.variant || "secondary"} className="font-normal capitalize"> <ProductionOrderStatusBadge status={order.status} />
{statusConfig[order.status]?.label || order.status}
</Badge>
</TableCell> </TableCell>
<TableCell className="text-center"> <TableCell className="text-center">
<div className="flex items-center justify-center gap-2"> <div className="flex items-center justify-center gap-2">
@@ -272,19 +277,21 @@ export default function ProductionIndex({ productionOrders, filters }: Props) {
</Link> </Link>
</Can> </Can>
<Can permission="production_orders.delete"> <Can permission="production_orders.delete">
<Button {(order.status === 'draft' || order.status === 'cancelled') && (
variant="outline" <Button
size="sm" variant="outline"
className="button-outlined-error" size="sm"
title="刪除" className="button-outlined-error"
onClick={() => { title="刪除"
if (confirm('確定要刪除此生產工單嗎?')) { onClick={() => {
router.delete(route('production-orders.destroy', order.id)); if (confirm('確定要刪除此生產工單嗎?')) {
} router.delete(route('production-orders.destroy', order.id));
}} }
> }}
<Trash2 className="h-4 w-4" /> >
</Button> <Trash2 className="h-4 w-4" />
</Button>
)}
</Can> </Can>
</div> </div>
</TableCell> </TableCell>

View File

@@ -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 { Button } from "@/Components/ui/button";
import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout"; 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 { getBreadcrumbs } from "@/utils/breadcrumb";
import { Badge } from "@/Components/ui/badge";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/Components/ui/table"; 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 { interface ProductionOrderItem {
// ... (後面保持不變)
id: number; id: number;
quantity_used: number; quantity_used: number;
unit?: { id: number; name: string } | null; unit?: { id: number; name: string } | null;
@@ -22,6 +35,7 @@ interface ProductionOrderItem {
arrival_date: string | null; arrival_date: string | null;
origin_country: string | null; origin_country: string | null;
product: { id: number; name: string; code: string } | null; product: { id: number; name: string; code: string } | null;
warehouse?: { id: number; name: string } | null;
source_purchase_order?: { source_purchase_order?: {
id: number; id: number;
code: string; code: string;
@@ -34,14 +48,16 @@ interface ProductionOrder {
id: number; id: number;
code: string; code: string;
product: { id: number; name: string; code: string; base_unit?: { name: string } | null } | null; 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; name: string } | null;
warehouse_id: number | null;
user: { id: number; name: string } | null; user: { id: number; name: string } | null;
output_batch_number: string; output_batch_number: string;
output_box_count: string | null; output_box_count: string | null;
output_quantity: number; output_quantity: number;
production_date: string; production_date: string;
expiry_date: string | null; expiry_date: string | null;
status: 'draft' | 'completed' | 'cancelled'; status: ProductionOrderStatus;
remark: string | null; remark: string | null;
created_at: string; created_at: string;
items: ProductionOrderItem[]; items: ProductionOrderItem[];
@@ -49,200 +65,363 @@ interface ProductionOrder {
interface Props { interface Props {
productionOrder: ProductionOrder; productionOrder: ProductionOrder;
warehouses: Warehouse[];
auth: {
user: {
id: number;
name: string;
roles: string[];
permissions: string[];
} | null;
};
} }
const statusConfig: Record<string, { label: string; variant: "default" | "secondary" | "destructive" | "outline" }> = { export default function ProductionShow({ productionOrder, warehouses, auth }: Props) {
draft: { label: "草稿", variant: "secondary" }, const [isWarehouseModalOpen, setIsWarehouseModalOpen] = useState(false);
completed: { label: "已完成", variant: "default" }, const { processing } = useForm({
cancelled: { label: "已取消", variant: "destructive" }, 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 ( return (
<AuthenticatedLayout breadcrumbs={getBreadcrumbs("productionOrdersShow")}> <AuthenticatedLayout breadcrumbs={getBreadcrumbs("productionOrdersShow")}>
<Head title={`生產單 ${productionOrder.code}`} /> <Head title={`生產單 ${productionOrder.code}`} />
<div className="container mx-auto p-6 max-w-7xl">
<WarehouseSelectionModal
isOpen={isWarehouseModalOpen}
onClose={() => setIsWarehouseModalOpen(false)}
onConfirm={(data) => handleStatusUpdate(PRODUCTION_ORDER_STATUS.COMPLETED, data)}
warehouses={warehouses}
processing={processing}
productCode={productionOrder.product?.code}
productId={productionOrder.product?.id}
/>
<div className="container mx-auto p-6 max-w-7xl animate-in fade-in duration-500">
{/* Header 區塊 */}
<div className="mb-6"> <div className="mb-6">
{/* 返回按鈕 (統一規範標題上方mb-4) */}
<Link href={route('production-orders.index')}> <Link href={route('production-orders.index')}>
<Button <Button
variant="outline" variant="outline"
className="gap-2 button-outlined-primary mb-6" className="gap-2 button-outlined-primary mb-4"
> >
<ArrowLeft className="h-4 w-4" /> <ArrowLeft className="h-4 w-4" />
</Button> </Button>
</Link> </Link>
<div className="flex items-center justify-between">
<div className="flex flex-wrap items-center justify-between gap-4">
<div> <div>
<h1 className="text-2xl font-bold text-grey-0 flex items-center gap-2"> <div className="flex items-center gap-3">
<Factory className="h-6 w-6 text-primary-main" /> <h1 className="text-2xl font-bold text-grey-0 flex items-center gap-2">
{productionOrder.code} <Factory className="h-6 w-6 text-primary-main" />
</h1> {productionOrder.code}
<p className="text-gray-500 mt-1"> </h1>
<ProductionOrderStatusBadge status={productionOrder.status} />
</div>
<p className="text-gray-500 text-sm mt-1">
{productionOrder.user?.name || '-'} | {formatDate(productionOrder.created_at)}
</p> </p>
</div> </div>
<Badge variant={statusConfig[productionOrder.status]?.variant || "secondary"} className="text-sm">
{statusConfig[productionOrder.status]?.label || productionOrder.status} {/* 操作按鈕區 (統一規範樣式類別) */}
</Badge> <div className="flex items-center gap-2">
{/* 草稿 -> 提交審核 */}
{productionOrder.status === PRODUCTION_ORDER_STATUS.DRAFT && (
<>
{canEdit && (
<Link href={route('production-orders.edit', productionOrder.id)}>
<Button variant="outline" className="gap-2 button-outlined-primary">
<FileText className="h-4 w-4" />
</Button>
</Link>
)}
<Button
onClick={() => handleStatusUpdate(PRODUCTION_ORDER_STATUS.PENDING)}
className="gap-2 button-filled-primary"
>
<Send className="h-4 w-4" />
</Button>
</>
)}
{/* 待審核 -> 核准 / 駁回 */}
{productionOrder.status === PRODUCTION_ORDER_STATUS.PENDING && canApprove && (
<>
<Button
variant="outline"
onClick={() => handleStatusUpdate(PRODUCTION_ORDER_STATUS.DRAFT)}
className="gap-2 button-outlined-error"
>
<ArrowLeft className="h-4 w-4" />
退稿
</Button>
<Button
onClick={() => handleStatusUpdate(PRODUCTION_ORDER_STATUS.APPROVED)}
className="gap-2 button-filled-success"
>
<CheckCircle2 className="h-4 w-4" />
</Button>
</>
)}
{/* 已核准 -> 開始製作 */}
{productionOrder.status === PRODUCTION_ORDER_STATUS.APPROVED && (
<Button
onClick={() => handleStatusUpdate(PRODUCTION_ORDER_STATUS.IN_PROGRESS)}
className="gap-2 button-filled-primary"
>
<PlayCircle className="h-4 w-4" />
()
</Button>
)}
{/* 製作中 -> 完成製作 */}
{productionOrder.status === PRODUCTION_ORDER_STATUS.IN_PROGRESS && (
<Button
onClick={() => setIsWarehouseModalOpen(true)}
className="gap-2 button-filled-primary"
>
<ArrowRightCircle className="h-4 w-4" />
()
</Button>
)}
{/* 可作廢狀態 (非已完成/已作廢/草稿之外) */}
{!([PRODUCTION_ORDER_STATUS.COMPLETED, PRODUCTION_ORDER_STATUS.CANCELLED, PRODUCTION_ORDER_STATUS.DRAFT] as ProductionOrderStatus[]).includes(productionOrder.status) && canCancel && (
<Button
variant="outline"
onClick={() => {
if (confirm('確定要作廢此生產工單嗎?此動作無法復原。')) {
handleStatusUpdate(PRODUCTION_ORDER_STATUS.CANCELLED);
}
}}
className="gap-2 button-outlined-error"
>
<Ban className="h-4 w-4" />
</Button>
)}
</div>
</div> </div>
</div> </div>
{/* 成品資訊 */} <div className="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-6">
<div className="bg-white p-6 rounded-lg shadow-sm border border-gray-200 mb-6"> {/* 狀態進度條 */}
<h2 className="text-lg font-semibold mb-4 flex items-center gap-2"> <div className="lg:col-span-3">
<Package className="h-5 w-5 text-gray-500" /> <ProductionStatusProgressBar currentStatus={productionOrder.status} />
</div>
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-x-8 gap-y-6"> {/* 成品資訊 (統一規範bg-white rounded-xl border border-gray-200 shadow-sm p-6) */}
<div className="space-y-1"> <div className="lg:col-span-2 space-y-6">
<p className="text-xs font-medium text-grey-2"></p> <div className="bg-white p-6 rounded-xl shadow-sm border border-gray-200 h-full">
<p className="font-medium text-grey-0"> <h2 className="text-lg font-semibold mb-6 flex items-center gap-2 text-grey-0">
{productionOrder.product?.name || '-'} <Package className="h-5 w-5 text-primary-main" />
<span className="text-gray-400 ml-2 text-sm font-normal">
({productionOrder.product?.code || '-'}) </h2>
</span> <div className="grid grid-cols-1 md:grid-cols-2 gap-x-8 gap-y-8">
</p> <div className="space-y-1.5">
</div> <p className="text-xs font-semibold text-grey-2 uppercase tracking-wider"></p>
<div className="space-y-1"> <div>
<p className="text-xs font-medium text-grey-2"></p> <p className="font-bold text-grey-0 text-lg">
<p className="font-mono font-medium text-primary-main"> {productionOrder.product?.name || '-'}
{productionOrder.output_batch_number} </p>
</p> <p className="text-gray-400 text-sm font-mono mt-0.5">
</div> {productionOrder.product?.code || '-'}
<div className="space-y-1"> </p>
<p className="text-xs font-medium text-grey-2"></p> </div>
<p className="font-medium text-grey-0"> </div>
{productionOrder.output_quantity.toLocaleString()} <div className="space-y-1.5">
{productionOrder.product?.base_unit?.name && ( <p className="text-xs font-semibold text-grey-2 uppercase tracking-wider"></p>
<span className="text-gray-400 ml-1 font-normal">{productionOrder.product.base_unit.name}</span> <p className="font-mono font-bold text-primary-main text-lg py-1 px-2 bg-primary-lightest rounded-md inline-block">
)} {productionOrder.output_batch_number}
{productionOrder.output_box_count && ( </p>
<span className="text-gray-400 ml-2 font-normal">({productionOrder.output_box_count} )</span> </div>
)} <div className="space-y-1.5">
</p> <p className="text-xs font-semibold text-grey-2 uppercase tracking-wider">/</p>
</div> <div className="flex items-baseline gap-1.5">
<div className="space-y-1"> <p className="font-bold text-grey-0 text-xl">
<p className="text-xs font-medium text-grey-2"></p> {formatQuantity(productionOrder.output_quantity)}
<div className="flex items-center gap-2"> </p>
<Warehouse className="h-4 w-4 text-gray-400" /> {productionOrder.product?.base_unit?.name && (
<p className="font-medium text-grey-0">{productionOrder.warehouse?.name || '-'}</p> <span className="text-grey-2 font-medium">{productionOrder.product.base_unit.name}</span>
</div> )}
</div> {productionOrder.output_box_count && (
<div className="space-y-1"> <span className="text-grey-3 ml-2 text-sm">({productionOrder.output_box_count} )</span>
<p className="text-xs font-medium text-grey-2"></p> )}
<div className="flex items-center gap-2"> </div>
<Calendar className="h-4 w-4 text-gray-400" /> </div>
<p className="font-medium text-grey-0">{productionOrder.production_date}</p> <div className="space-y-1.5">
</div> <p className="text-xs font-semibold text-grey-2 uppercase tracking-wider"></p>
</div> <div className="flex items-center gap-2 bg-grey-5 p-2 rounded-lg border border-grey-4">
<div className="space-y-1"> <Warehouse className="h-4 w-4 text-grey-3" />
<p className="text-xs font-medium text-grey-2"></p> <p className="font-semibold text-grey-0">{productionOrder.warehouse?.name || (productionOrder.status === PRODUCTION_ORDER_STATUS.COMPLETED ? '系統錯誤' : '待選取')}</p>
<div className="flex items-center gap-2"> </div>
<Calendar className="h-4 w-4 text-gray-400" /> </div>
<p className="font-medium text-grey-0">{productionOrder.expiry_date || '-'}</p>
</div>
</div>
<div className="space-y-1">
<p className="text-xs font-medium text-grey-2"></p>
<div className="flex items-center gap-2">
<User className="h-4 w-4 text-gray-400" />
<p className="font-medium text-grey-0">{productionOrder.user?.name || '-'}</p>
</div> </div>
{productionOrder.remark && (
<div className="mt-8 pt-6 border-t border-grey-4 transition-all hover:bg-grey-5 p-2 rounded-lg">
<div className="flex items-start gap-3">
<FileText className="h-5 w-5 text-grey-3 mt-0.5" />
<div>
<p className="text-xs font-semibold text-grey-2 uppercase tracking-wider mb-1"></p>
<p className="text-grey-1 leading-relaxed">{productionOrder.remark}</p>
</div>
</div>
</div>
)}
</div> </div>
</div> </div>
{productionOrder.remark && ( {/* 次要資訊 */}
<div className="mt-4 pt-4 border-t border-gray-200"> <div className="lg:col-span-1">
<div className="flex items-start gap-2"> <div className="bg-white p-6 rounded-xl shadow-sm border border-gray-200 h-full space-y-8">
<FileText className="h-4 w-4 text-gray-400 mt-1" /> <h2 className="text-lg font-semibold flex items-center gap-2 text-grey-0">
<div> <Calendar className="h-5 w-5 text-primary-main" />
<p className="text-sm text-gray-500"></p>
<p className="text-gray-700">{productionOrder.remark}</p> </h2>
<div className="space-y-6">
<div className="flex items-start gap-4 p-3 rounded-lg bg-primary-lightest border border-primary-light/20">
<Calendar className="h-5 w-5 text-primary-main mt-1" />
<div>
<p className="text-xs font-bold text-primary-main/60 uppercase"></p>
<p className="font-bold text-grey-0 text-lg">{formatDate(productionOrder.production_date)}</p>
</div>
</div>
<div className="flex items-start gap-4 p-3 rounded-lg bg-orange-50 border border-orange-100">
<Calendar className="h-5 w-5 text-orange-600 mt-1" />
<div>
<p className="text-xs font-bold text-orange-900/50 uppercase"></p>
<p className="font-bold text-orange-900 text-lg">{formatDate(productionOrder.expiry_date)}</p>
</div>
</div>
<div className="flex items-start gap-4 p-3 rounded-lg bg-grey-5 border border-grey-4">
<User className="h-5 w-5 text-grey-2 mt-1" />
<div>
<p className="text-xs font-bold text-grey-3 uppercase"></p>
<p className="font-bold text-grey-1 text-lg">{productionOrder.user?.name || '-'}</p>
</div>
</div> </div>
</div> </div>
</div> </div>
)} </div>
</div> </div>
{/* 原物料使用明細 (BOM) */} {/* 原物料使用明細 (BOM) (統一規範bg-white rounded-xl border border-gray-200 shadow-sm p-6) */}
<div className="bg-white p-6 rounded-lg shadow-sm border border-gray-200"> <div className="bg-white p-6 rounded-xl shadow-sm border border-gray-200 overflow-hidden mb-6">
<h2 className="text-lg font-semibold mb-4 flex items-center gap-2"> <div className="flex items-center justify-between mb-6">
<Link2 className="h-5 w-5 text-gray-500" /> <h2 className="text-lg font-semibold flex items-center gap-2 text-grey-0">
使 (BOM) - <Link2 className="h-5 w-5 text-primary-main" />
</h2>
</h2>
<Badge variant="outline" className="text-grey-3 font-medium">
{productionOrder.items.length}
</Badge>
</div>
{productionOrder.items.length === 0 ? ( {productionOrder.items.length === 0 ? (
<p className="text-center text-gray-500 py-8"></p> <div className="flex flex-col items-center justify-center py-16 text-grey-3 bg-grey-5 rounded-xl border-2 border-dashed border-grey-4">
<Package className="h-10 w-10 mb-4 opacity-20 text-grey-2" />
<p></p>
</div>
) : ( ) : (
<div className="bg-white rounded-xl border border-gray-100 shadow-sm overflow-hidden"> <div className="rounded-xl border border-grey-4 overflow-hidden shadow-sm">
<Table> <Table>
<TableHeader className="bg-gray-50"> <TableHeader className="bg-grey-5/80 backdrop-blur-sm transition-colors">
<TableRow> <TableRow className="hover:bg-transparent border-b-grey-4">
<TableHead className="px-4 py-3 text-left text-xs font-medium text-grey-2"> <TableHead className="px-6 py-4 text-xs font-bold text-grey-2 uppercase tracking-widest leading-none"></TableHead>
<TableHead className="px-6 py-4 text-xs font-bold text-grey-2 uppercase tracking-widest leading-none"></TableHead>
</TableHead> <TableHead className="px-6 py-4 text-xs font-bold text-grey-2 uppercase tracking-widest leading-none">使</TableHead>
<TableHead className="px-4 py-3 text-left text-xs font-medium text-grey-2"> <TableHead className="px-6 py-4 text-xs font-bold text-grey-2 uppercase tracking-widest leading-none text-center"></TableHead>
<TableHead className="px-6 py-4 text-xs font-bold text-grey-2 uppercase tracking-widest leading-none">使</TableHead>
</TableHead> <TableHead className="px-6 py-4 text-xs font-bold text-grey-2 uppercase tracking-widest leading-none"></TableHead>
<TableHead className="px-4 py-3 text-left text-xs font-medium text-grey-2">
</TableHead>
<TableHead className="px-4 py-3 text-left text-xs font-medium text-grey-2">
</TableHead>
<TableHead className="px-4 py-3 text-left text-xs font-medium text-grey-2">
使
</TableHead>
<TableHead className="px-4 py-3 text-left text-xs font-medium text-grey-2">
</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{productionOrder.items.map((item) => ( {productionOrder.items.map((item) => (
<TableRow key={item.id} className="hover:bg-gray-50/50"> <TableRow key={item.id} className="hover:bg-grey-5/80 transition-colors border-b-grey-4 last:border-0">
<TableCell className="px-4 py-4 text-sm"> <TableCell className="px-6 py-5">
<div className="font-medium text-grey-0">{item.inventory?.product?.name || '-'}</div> <div className="font-bold text-grey-0">{item.inventory?.product?.name || '-'}</div>
<div className="text-gray-400 text-xs"> <div className="text-grey-3 text-xs font-mono mt-1 px-1.5 py-0.5 bg-grey-5 border border-grey-4 rounded inline-block">
{item.inventory?.product?.code || '-'} {item.inventory?.product?.code || '-'}
</div> </div>
</TableCell> </TableCell>
<TableCell className="px-4 py-4 text-sm font-mono text-primary-main"> <TableCell className="px-6 py-5">
{item.inventory?.batch_number || '-'} <div className="text-grey-0 font-medium">{item.inventory?.warehouse?.name || '-'}</div>
{item.inventory?.box_number && (
<span className="text-gray-300 ml-1">#{item.inventory.box_number}</span>
)}
</TableCell> </TableCell>
<TableCell className="px-4 py-4 text-sm text-grey-1"> <TableCell className="px-6 py-5">
{item.inventory?.origin_country || '-'} <div className="font-mono font-bold text-primary-main bg-primary-lightest border border-primary-light/10 px-2 py-1 rounded inline-flex items-center gap-2">
{item.inventory?.batch_number || '-'}
{item.inventory?.box_number && (
<span className="text-primary-main/60 text-[10px] bg-white px-1 rounded shadow-sm">#{item.inventory.box_number}</span>
)}
</div>
</TableCell> </TableCell>
<TableCell className="px-4 py-4 text-sm text-grey-1"> <TableCell className="px-6 py-5 text-center">
{item.inventory?.arrival_date || '-'} <span className="px-3 py-1 bg-grey-5 border border-grey-4 rounded-full text-xs font-bold text-grey-2">
{item.inventory?.origin_country || '-'}
</span>
</TableCell> </TableCell>
<TableCell className="px-4 py-4 text-sm font-medium text-grey-0"> <TableCell className="px-6 py-5">
{item.quantity_used.toLocaleString()} <div className="flex items-baseline gap-1">
{item.unit?.name && ( <span className="font-bold text-grey-0 text-base">{formatQuantity(item.quantity_used)}</span>
<span className="text-gray-400 ml-1 font-normal text-xs">{item.unit.name}</span> {item.unit?.name && (
)} <span className="text-grey-3 text-xs font-medium uppercase">{item.unit.name}</span>
)}
</div>
</TableCell> </TableCell>
<TableCell className="px-4 py-4 text-sm"> <TableCell className="px-6 py-5">
{item.inventory?.source_purchase_order ? ( {item.inventory?.source_purchase_order ? (
<div className="flex flex-col"> <div className="group flex flex-col">
<Link <Link
href={route('purchase-orders.show', item.inventory.source_purchase_order.id)} href={route('purchase-orders.show', item.inventory.source_purchase_order.id)}
className="text-primary-main hover:underline font-medium" className="text-primary-main hover:text-primary-dark font-bold inline-flex items-center gap-1 group-hover:underline transition-all"
> >
{item.inventory.source_purchase_order.code} {item.inventory.source_purchase_order.code}
<ArrowLeft className="h-3 w-3 rotate-180 opacity-0 group-hover:opacity-100 transition-opacity" />
</Link> </Link>
{item.inventory.source_purchase_order.vendor && ( {item.inventory.source_purchase_order.vendor && (
<span className="text-[11px] text-gray-400 mt-0.5"> <span className="text-[10px] text-grey-3 font-bold uppercase tracking-tight mt-0.5 whitespace-nowrap overflow-hidden text-ellipsis max-w-[150px]">
{item.inventory.source_purchase_order.vendor.name} {item.inventory.source_purchase_order.vendor.name}
</span> </span>
)} )}
</div> </div>
) : ( ) : (
<span className="text-gray-400">-</span> <span className="text-grey-4"></span>
)} )}
</TableCell> </TableCell>
</TableRow> </TableRow>

View File

@@ -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<ProductionOrderStatus, { label: string; variant: "default" | "secondary" | "destructive" | "outline" | "success" | "warning" | "info" }> = {
[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",
},
};

41
resources/js/lib/date.ts Normal file
View File

@@ -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");
}

View File

@@ -4,3 +4,10 @@ import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) { export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs)); 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)));
}