From 95d8dc2e84825ce9163d0e632da27839c6a70d97 Mon Sep 17 00:00:00 2001
From: sky121113
Date: Tue, 27 Jan 2026 17:23:31 +0800
Subject: [PATCH] =?UTF-8?q?feat:=20=E7=B5=B1=E4=B8=80=E9=80=B2=E8=B2=A8?=
=?UTF-8?q?=E5=96=AE=20UI=E3=80=81=E4=BF=AE=E5=BE=A9=E5=BA=AB=E5=AD=98?=
=?UTF-8?q?=E7=95=B0=E5=8B=95=E7=B4=80=E9=8C=84=E8=88=87=E5=BB=A0=E5=95=86?=
=?UTF-8?q?=E8=A9=B3=E6=83=85=E9=A1=AF=E7=A4=BA=E5=A0=B1=E9=8C=AF?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../Controllers/GoodsReceiptController.php | 138 +++-
.../Controllers/InventoryController.php | 56 +-
app/Modules/Inventory/Models/GoodsReceipt.php | 2 +-
.../Inventory/Models/GoodsReceiptItem.php | 2 +-
app/Modules/Inventory/Routes/web.php | 1 +
.../Inventory/Services/InventoryService.php | 8 +-
.../Contracts/ProcurementServiceInterface.php | 23 +
.../Controllers/PurchaseOrderController.php | 2 +-
.../Controllers/VendorController.php | 19 +-
.../Services/ProcurementService.php | 21 +-
..._153000_update_purchase_order_statuses.php | 32 +
.../Inventory/GoodsReceiptActions.tsx | 101 +++
.../Inventory/GoodsReceiptStatusBadge.tsx | 46 ++
.../Inventory/GoodsReceiptTable.tsx | 258 +++++++
.../PurchaseOrderStatusBadge.tsx | 30 +-
.../PurchaseOrder/StatusProgressBar.tsx | 14 +-
.../Pages/Inventory/GoodsReceipt/Create.tsx | 658 +++++++++++-------
.../js/Pages/Inventory/GoodsReceipt/Index.tsx | 345 +++++++--
.../js/Pages/Inventory/GoodsReceipt/Show.tsx | 221 ++++++
resources/js/Pages/PurchaseOrder/Index.tsx | 14 +-
resources/js/Pages/PurchaseOrder/Show.tsx | 34 +-
resources/js/constants/purchase-order.ts | 13 +-
resources/js/types/goods-receipt.ts | 27 +
resources/js/types/purchase-order.ts | 14 +-
24 files changed, 1613 insertions(+), 466 deletions(-)
create mode 100644 database/migrations/tenant/2026_01_27_153000_update_purchase_order_statuses.php
create mode 100644 resources/js/Components/Inventory/GoodsReceiptActions.tsx
create mode 100644 resources/js/Components/Inventory/GoodsReceiptStatusBadge.tsx
create mode 100644 resources/js/Components/Inventory/GoodsReceiptTable.tsx
create mode 100644 resources/js/Pages/Inventory/GoodsReceipt/Show.tsx
create mode 100644 resources/js/types/goods-receipt.ts
diff --git a/app/Modules/Inventory/Controllers/GoodsReceiptController.php b/app/Modules/Inventory/Controllers/GoodsReceiptController.php
index 111a915..40d14c4 100644
--- a/app/Modules/Inventory/Controllers/GoodsReceiptController.php
+++ b/app/Modules/Inventory/Controllers/GoodsReceiptController.php
@@ -7,6 +7,7 @@ use App\Modules\Inventory\Services\GoodsReceiptService;
use App\Modules\Inventory\Services\InventoryService;
use App\Modules\Procurement\Contracts\ProcurementServiceInterface;
use Illuminate\Http\Request;
+use App\Modules\Procurement\Models\Vendor;
use Inertia\Inertia;
use App\Modules\Inventory\Models\GoodsReceipt;
@@ -29,58 +30,125 @@ class GoodsReceiptController extends Controller
public function index(Request $request)
{
$query = GoodsReceipt::query()
- ->with(['warehouse']); // Vendor info might need fetching separately or stored as snapshot if cross-module strict
+ ->select(['id', 'code', 'type', 'warehouse_id', 'vendor_id', 'received_date', 'status', 'created_at'])
+ ->with(['warehouse'])
+ ->withSum('items', 'total_amount');
- if ($request->has('search')) {
+ // 關鍵字搜尋(單號)
+ if ($request->filled('search')) {
$search = $request->input('search');
$query->where('code', 'like', "%{$search}%");
}
+ // 狀態篩選
+ if ($request->filled('status') && $request->input('status') !== 'all') {
+ $query->where('status', $request->input('status'));
+ }
+
+ // 倉庫篩選
+ if ($request->filled('warehouse_id') && $request->input('warehouse_id') !== 'all') {
+ $query->where('warehouse_id', $request->input('warehouse_id'));
+ }
+
+ // 日期範圍篩選
+ if ($request->filled('date_start')) {
+ $query->whereDate('received_date', '>=', $request->input('date_start'));
+ }
+ if ($request->filled('date_end')) {
+ $query->whereDate('received_date', '<=', $request->input('date_end'));
+ }
+
+ // 每頁筆數
+ $perPage = $request->input('per_page', 10);
+
$receipts = $query->orderBy('created_at', 'desc')
- ->paginate(10)
+ ->paginate($perPage)
->withQueryString();
- // Hydrate Vendor Names (Manual hydration to avoid cross-module relation)
- // Or if we stored vendor_name in DB, we could use that.
- // For now, let's fetch vendors via Service if needed, or just let frontend handle it if we passed IDs?
- // Let's implement hydration properly.
- $vendorIds = $receipts->pluck('vendor_id')->unique()->toArray();
- if (!empty($vendorIds)) {
- // Check if ProcurementService has getVendorsByIds? No directly exposed method in interface yet.
- // Let's assume we can add it or just fetch POs to get vendors?
- // Actually, for simplicity and performance in Strict Mode, often we just fetch minimal data.
- // Or we can use `App\Modules\Procurement\Models\Vendor` directly ONLY for reading if allowed, but strict mode says NO.
- // But we don't have getVendorsByIds in interface.
- // User requirement: "從採購單帶入".
- // Let's just pass IDs for now, or use a method if available.
- // Wait, I can't modify Interface easily without user approval if it's big change.
- // But I just added updateReceivedQuantity.
- // Let's skip vendor name hydration for index for a moment and focus on Create first, or use a direct DB query via a DTO service?
- // Actually, I can use `DB::table('vendors')` as a workaround if needed, but that's dirty.
- // Let's revisit Service Interface.
- }
-
- // Quick fix: Add `vendor` relation to GoodsReceipt only if we decided to allow it or if we stored snapshot.
- // Plan said: `vendor_id`: foreignId.
- // Ideally we should have stored `vendor_name` in `goods_receipts` table for snapshot.
- // I didn't add it in migration.
- // Let's rely on `ProcurementServiceInterface` to get vendor info if possible.
- // I will add a method to get Vendors or POs.
+ // Manual Hydration for Vendors (Cross-Module)
+ $vendorIds = collect($receipts->items())->pluck('vendor_id')->unique()->filter()->toArray();
+ $vendors = $this->procurementService->getVendorsByIds($vendorIds)->keyBy('id');
+
+ $receipts->getCollection()->transform(function ($receipt) use ($vendors) {
+ $receipt->vendor = $vendors->get($receipt->vendor_id);
+ return $receipt;
+ });
+
+ // 取得倉庫列表用於篩選
+ $warehouses = $this->inventoryService->getAllWarehouses();
return Inertia::render('Inventory/GoodsReceipt/Index', [
'receipts' => $receipts,
- 'filters' => $request->only(['search']),
+ 'filters' => $request->only(['search', 'status', 'warehouse_id', 'date_start', 'date_end', 'per_page']),
+ 'warehouses' => $warehouses,
+ ]);
+ }
+
+ public function show($id)
+ {
+ $receipt = GoodsReceipt::with([
+ 'warehouse',
+ 'items.product.category',
+ 'items.product.baseUnit'
+ ])->findOrFail($id);
+
+ // Manual Hydration for Vendor (Cross-Module)
+ if ($receipt->vendor_id) {
+ $receipt->vendor = $this->procurementService->getVendorsByIds([$receipt->vendor_id])->first();
+ }
+
+ // 手動計算統計資訊 (如果 Model 沒有定義對應的 Attribute)
+ $receipt->items_sum_total_amount = $receipt->items->sum('total_amount');
+
+ return Inertia::render('Inventory/GoodsReceipt/Show', [
+ 'receipt' => $receipt
]);
}
public function create()
{
+ // 取得待進貨的採購單列表(用於標準採購類型選擇)
+ $pendingPOs = $this->procurementService->getPendingPurchaseOrders();
+
+ // 提取所有產品 ID 以便跨模組水和資料
+ $productIds = $pendingPOs->flatMap(fn($po) => $po->items->pluck('product_id'))->unique()->filter()->toArray();
+ $products = $this->inventoryService->getProductsByIds($productIds)->keyBy('id');
+
+ // 處理採購單資料,計算剩餘可收貨數量
+ $formattedPOs = $pendingPOs->map(function ($po) use ($products) {
+ return [
+ 'id' => $po->id,
+ 'code' => $po->code,
+ 'status' => $po->status,
+ 'vendor_id' => $po->vendor_id,
+ 'vendor_name' => $po->vendor?->name ?? '',
+ 'warehouse_id' => $po->warehouse_id,
+ 'order_date' => $po->order_date,
+ 'items' => $po->items->map(function ($item) use ($products) {
+ $product = $products->get($item->product_id);
+ $remaining = max(0, $item->quantity - ($item->received_quantity ?? 0));
+ return [
+ 'id' => $item->id,
+ 'product_id' => $item->product_id,
+ 'product_name' => $product?->name ?? '',
+ 'product_code' => $product?->code ?? '',
+ 'unit' => $product?->baseUnit?->name ?? '個',
+ 'quantity' => $item->quantity,
+ 'received_quantity' => $item->received_quantity ?? 0,
+ 'remaining' => $remaining,
+ 'unit_price' => $item->unit_price,
+ ];
+ })->filter(fn($item) => $item['remaining'] > 0)->values(),
+ ];
+ })->filter(fn($po) => $po['items']->count() > 0)->values();
+
+ // 取得所有廠商列表(用於雜項入庫/其他類型選擇)
+ $vendors = $this->procurementService->getAllVendors();
+
return Inertia::render('Inventory/GoodsReceipt/Create', [
'warehouses' => $this->inventoryService->getAllWarehouses(),
- // Vendors? We need to select PO, not Vendor directly maybe?
- // Designing the UI: Select PO -> fills Vendor and Items.
- // So we need a way to search POs by code or vendor.
- // We can provide an API for searching POs.
+ 'pendingPurchaseOrders' => $formattedPOs,
+ 'vendors' => $vendors,
]);
}
@@ -140,7 +208,7 @@ class GoodsReceiptController extends Controller
'id' => $product->id,
'name' => $product->name,
'code' => $product->code,
- 'unit' => $product->unit, // Ensure unit is included
+ 'unit' => $product->baseUnit?->name ?? '個', // Ensure unit is included
'price' => $product->purchase_price ?? 0, // Suggest price from product info if available
];
});
diff --git a/app/Modules/Inventory/Controllers/InventoryController.php b/app/Modules/Inventory/Controllers/InventoryController.php
index eb28985..8f5cca7 100644
--- a/app/Modules/Inventory/Controllers/InventoryController.php
+++ b/app/Modules/Inventory/Controllers/InventoryController.php
@@ -10,6 +10,7 @@ use Inertia\Inertia;
use App\Modules\Inventory\Models\Warehouse;
use App\Modules\Inventory\Models\Product;
use App\Modules\Inventory\Models\Inventory;
+use App\Modules\Inventory\Models\InventoryTransaction;
use App\Modules\Inventory\Models\WarehouseProductSafetyStock;
use App\Modules\Core\Contracts\CoreServiceInterface;
@@ -482,7 +483,60 @@ class InventoryController extends Controller
$productId = $request->query('productId');
if ($productId) {
- // ... (略) ...
+ $product = Product::findOrFail($productId);
+ // 取得該倉庫中該商品的所有批號 ID
+ $inventoryIds = Inventory::where('warehouse_id', $warehouse->id)
+ ->where('product_id', $productId)
+ ->pluck('id')
+ ->toArray();
+
+ $transactionsRaw = InventoryTransaction::whereIn('inventory_id', $inventoryIds)
+ ->with('inventory') // 需要批號資訊
+ ->orderBy('actual_time', 'desc')
+ ->orderBy('id', 'desc')
+ ->get();
+
+ // 手動 Hydrate 使用者資料
+ $userIds = $transactionsRaw->pluck('user_id')->filter()->unique()->toArray();
+ $users = $this->coreService->getUsersByIds($userIds)->keyBy('id');
+
+ // 計算商品在該倉庫的總量(不分批號)
+ $currentRunningTotal = (float) Inventory::whereIn('id', $inventoryIds)->sum('quantity');
+
+ $transactions = $transactionsRaw->map(function ($tx) use ($users, &$currentRunningTotal) {
+ $user = $tx->user_id ? ($users[$tx->user_id] ?? null) : null;
+ $balanceAfter = $currentRunningTotal;
+
+ // 為下一筆(較舊的)紀錄更新 Running Total
+ $currentRunningTotal -= (float) $tx->quantity;
+
+ return [
+ 'id' => (string) $tx->id,
+ 'type' => $tx->type,
+ 'quantity' => (float) $tx->quantity,
+ 'unit_cost' => (float) $tx->unit_cost,
+ 'balanceAfter' => (float) $balanceAfter, // 顯示該商品在倉庫的累計結餘
+ 'reason' => $tx->reason,
+ 'userName' => $user ? $user->name : '系統',
+ 'actualTime' => $tx->actual_time ? $tx->actual_time->format('Y-m-d H:i') : $tx->created_at->format('Y-m-d H:i'),
+ 'batchNumber' => $tx->inventory?->batch_number ?? '-', // 補上批號資訊
+ ];
+ });
+
+ // 重新計算目前的總量(用於 Header 顯示,確保一致性)
+ $totalQuantity = Inventory::whereIn('id', $inventoryIds)->sum('quantity');
+
+ return Inertia::render('Warehouse/InventoryHistory', [
+ 'warehouse' => $warehouse,
+ 'inventory' => [
+ 'id' => null, // 跨批號查詢沒有單一 ID
+ 'productName' => $product->name,
+ 'productCode' => $product->code,
+ 'batchNumber' => '所有批號',
+ 'quantity' => (float) $totalQuantity,
+ ],
+ 'transactions' => $transactions
+ ]);
}
if ($inventoryId) {
diff --git a/app/Modules/Inventory/Models/GoodsReceipt.php b/app/Modules/Inventory/Models/GoodsReceipt.php
index 1c42dcb..4f1fbdd 100644
--- a/app/Modules/Inventory/Models/GoodsReceipt.php
+++ b/app/Modules/Inventory/Models/GoodsReceipt.php
@@ -24,7 +24,7 @@ class GoodsReceipt extends Model
];
protected $casts = [
- 'received_date' => 'date',
+ 'received_date' => 'date:Y-m-d',
];
public function getActivitylogOptions(): \Spatie\Activitylog\LogOptions
diff --git a/app/Modules/Inventory/Models/GoodsReceiptItem.php b/app/Modules/Inventory/Models/GoodsReceiptItem.php
index ad63335..3d0015d 100644
--- a/app/Modules/Inventory/Models/GoodsReceiptItem.php
+++ b/app/Modules/Inventory/Models/GoodsReceiptItem.php
@@ -24,7 +24,7 @@ class GoodsReceiptItem extends Model
'quantity_received' => 'decimal:2',
'unit_price' => 'decimal:2', // 暫定價格
'total_amount' => 'decimal:2',
- 'expiry_date' => 'date',
+ 'expiry_date' => 'date:Y-m-d',
];
public function goodsReceipt()
diff --git a/app/Modules/Inventory/Routes/web.php b/app/Modules/Inventory/Routes/web.php
index 21bc79f..12f19d2 100644
--- a/app/Modules/Inventory/Routes/web.php
+++ b/app/Modules/Inventory/Routes/web.php
@@ -82,6 +82,7 @@ Route::middleware('auth')->group(function () {
Route::middleware('permission:goods_receipts.view')->group(function () {
Route::get('/goods-receipts', [\App\Modules\Inventory\Controllers\GoodsReceiptController::class, 'index'])->name('goods-receipts.index');
Route::get('/goods-receipts/create', [\App\Modules\Inventory\Controllers\GoodsReceiptController::class, 'create'])->middleware('permission:goods_receipts.create')->name('goods-receipts.create');
+ Route::get('/goods-receipts/{goods_receipt}', [\App\Modules\Inventory\Controllers\GoodsReceiptController::class, 'show'])->name('goods-receipts.show');
Route::post('/goods-receipts', [\App\Modules\Inventory\Controllers\GoodsReceiptController::class, 'store'])->middleware('permission:goods_receipts.create')->name('goods-receipts.store');
Route::get('/api/goods-receipts/search-pos', [\App\Modules\Inventory\Controllers\GoodsReceiptController::class, 'searchPOs'])->name('goods-receipts.search-pos');
Route::get('/api/goods-receipts/search-products', [\App\Modules\Inventory\Controllers\GoodsReceiptController::class, 'searchProducts'])->name('goods-receipts.search-products');
diff --git a/app/Modules/Inventory/Services/InventoryService.php b/app/Modules/Inventory/Services/InventoryService.php
index d1fcd4c..d8e0d99 100644
--- a/app/Modules/Inventory/Services/InventoryService.php
+++ b/app/Modules/Inventory/Services/InventoryService.php
@@ -17,7 +17,7 @@ class InventoryService implements InventoryServiceInterface
public function getAllProducts()
{
- return Product::with(['baseUnit'])->get();
+ return Product::with(['baseUnit', 'largeUnit'])->get();
}
public function getUnits()
@@ -32,17 +32,17 @@ class InventoryService implements InventoryServiceInterface
public function getProduct(int $id)
{
- return Product::find($id);
+ return Product::with(['baseUnit', 'largeUnit'])->find($id);
}
public function getProductsByIds(array $ids)
{
- return Product::whereIn('id', $ids)->get();
+ return Product::whereIn('id', $ids)->with(['baseUnit', 'largeUnit'])->get();
}
public function getProductsByName(string $name)
{
- return Product::where('name', 'like', "%{$name}%")->get();
+ return Product::where('name', 'like', "%{$name}%")->with(['baseUnit', 'largeUnit'])->get();
}
public function getWarehouse(int $id)
diff --git a/app/Modules/Procurement/Contracts/ProcurementServiceInterface.php b/app/Modules/Procurement/Contracts/ProcurementServiceInterface.php
index 9b1d5b5..c0025fa 100644
--- a/app/Modules/Procurement/Contracts/ProcurementServiceInterface.php
+++ b/app/Modules/Procurement/Contracts/ProcurementServiceInterface.php
@@ -56,4 +56,27 @@ interface ProcurementServiceInterface
* @return Collection
*/
public function searchVendors(string $query): Collection;
+
+ /**
+ * 取得所有待進貨的採購單列表(不需搜尋條件)。
+ * 用於進貨單頁面直接顯示可選擇的採購單。
+ *
+ * @return Collection
+ */
+ public function getPendingPurchaseOrders(): Collection;
+
+ /**
+ * 取得所有廠商列表。
+ *
+ * @return Collection
+ */
+ public function getAllVendors(): Collection;
+
+ /**
+ * Get vendors by multiple IDs.
+ *
+ * @param array $ids
+ * @return Collection
+ */
+ public function getVendorsByIds(array $ids): Collection;
}
diff --git a/app/Modules/Procurement/Controllers/PurchaseOrderController.php b/app/Modules/Procurement/Controllers/PurchaseOrderController.php
index a85e715..68909f6 100644
--- a/app/Modules/Procurement/Controllers/PurchaseOrderController.php
+++ b/app/Modules/Procurement/Controllers/PurchaseOrderController.php
@@ -420,7 +420,7 @@ class PurchaseOrderController extends Controller
'order_date' => 'required|date', // 新增驗證
'expected_delivery_date' => 'nullable|date',
'remark' => 'nullable|string',
- 'status' => 'required|string|in:draft,pending,processing,shipping,confirming,completed,cancelled,partial',
+ 'status' => 'required|string|in:draft,pending,approved,partial,completed,closed,cancelled',
'invoice_number' => ['nullable', 'string', 'max:11', 'regex:/^[A-Z]{2}-\d{8}$/'],
'invoice_date' => 'nullable|date',
'invoice_amount' => 'nullable|numeric|min:0',
diff --git a/app/Modules/Procurement/Controllers/VendorController.php b/app/Modules/Procurement/Controllers/VendorController.php
index a5f5dc7..b8521ce 100644
--- a/app/Modules/Procurement/Controllers/VendorController.php
+++ b/app/Modules/Procurement/Controllers/VendorController.php
@@ -95,14 +95,15 @@ class VendorController extends Controller
if (!$product) return null;
return (object) [
- 'id' => (string) $pivot->id,
- 'productId' => (string) $product->id,
- 'productName' => $product->name,
- 'unit' => $product->baseUnit?->name ?? 'N/A',
- 'baseUnit' => $product->baseUnit?->name,
- 'largeUnit' => $product->largeUnit?->name,
- 'conversionRate' => (float) $product->conversion_rate,
- 'lastPrice' => (float) $pivot->last_price,
+ 'id' => (string) $product->id, // Frontend expects product ID here as p.id
+ 'name' => $product->name,
+ 'baseUnit' => $product->baseUnit ? (object)['name' => $product->baseUnit->name] : null,
+ 'largeUnit' => $product->largeUnit ? (object)['name' => $product->largeUnit->name] : null,
+ 'conversion_rate' => (float) $product->conversion_rate,
+ 'purchase_unit' => $product->purchaseUnit?->name,
+ 'pivot' => (object) [
+ 'last_price' => (float) $pivot->last_price,
+ ],
];
})->filter()->values();
@@ -119,7 +120,7 @@ class VendorController extends Controller
'email' => $vendor->email,
'address' => $vendor->address,
'remark' => $vendor->remark,
- 'supplyProducts' => $supplyProducts,
+ 'products' => $supplyProducts, // Changed from supplyProducts to products
];
return Inertia::render('Vendor/Show', [
diff --git a/app/Modules/Procurement/Services/ProcurementService.php b/app/Modules/Procurement/Services/ProcurementService.php
index dc36a64..2618245 100644
--- a/app/Modules/Procurement/Services/ProcurementService.php
+++ b/app/Modules/Procurement/Services/ProcurementService.php
@@ -62,7 +62,7 @@ class ProcurementService implements ProcurementServiceInterface
public function searchPendingPurchaseOrders(string $query): Collection
{
return PurchaseOrder::with(['vendor', 'items'])
- ->whereIn('status', ['processing', 'shipping', 'partial'])
+ ->whereIn('status', ['approved', 'partial'])
->where(function($q) use ($query) {
$q->where('code', 'like', "%{$query}%")
->orWhereHas('vendor', function($vq) use ($query) {
@@ -80,4 +80,23 @@ class ProcurementService implements ProcurementServiceInterface
->limit(20)
->get(['id', 'name', 'code']);
}
+
+ public function getPendingPurchaseOrders(): Collection
+ {
+ return PurchaseOrder::with(['vendor', 'items'])
+ ->whereIn('status', ['approved', 'partial'])
+ ->orderBy('created_at', 'desc')
+ ->limit(50)
+ ->get();
+ }
+
+ public function getAllVendors(): Collection
+ {
+ return \App\Modules\Procurement\Models\Vendor::orderBy('name')->get(['id', 'name', 'code']);
+ }
+
+ public function getVendorsByIds(array $ids): Collection
+ {
+ return \App\Modules\Procurement\Models\Vendor::whereIn('id', $ids)->get(['id', 'name', 'code']);
+ }
}
diff --git a/database/migrations/tenant/2026_01_27_153000_update_purchase_order_statuses.php b/database/migrations/tenant/2026_01_27_153000_update_purchase_order_statuses.php
new file mode 100644
index 0000000..a4fc538
--- /dev/null
+++ b/database/migrations/tenant/2026_01_27_153000_update_purchase_order_statuses.php
@@ -0,0 +1,32 @@
+whereIn('status', ['processing', 'shipping', 'confirming'])
+ ->update(['status' => 'approved']);
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ // Cannot easily reverse without knowing original status,
+ // but typically we can revert 'approved' back to 'processing' as a safeguard if needed,
+ // or just leave it since 'approved' is broader.
+ // For strict reversal, we might try to map back, but effectively this is a one-way consolidation.
+ // We will leave it as is for down/safe side.
+ }
+};
diff --git a/resources/js/Components/Inventory/GoodsReceiptActions.tsx b/resources/js/Components/Inventory/GoodsReceiptActions.tsx
new file mode 100644
index 0000000..f336671
--- /dev/null
+++ b/resources/js/Components/Inventory/GoodsReceiptActions.tsx
@@ -0,0 +1,101 @@
+import { useState } from "react";
+import { Eye, Trash2 } from "lucide-react";
+import { Button } from "@/Components/ui/button";
+import { Link, useForm } from "@inertiajs/react";
+import { toast } from "sonner";
+import { Can } from "@/Components/Permission/Can";
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from "@/Components/ui/alert-dialog";
+
+export interface GoodsReceipt {
+ id: number;
+ code: string;
+ warehouse_id: number;
+ warehouse?: { name: string };
+ vendor_id?: number;
+ vendor?: { name: string };
+ received_date: string;
+ status: string;
+ type?: string;
+ items_sum_total_amount?: number;
+ user?: { name: string };
+}
+
+export default function GoodsReceiptActions({
+ receipt,
+}: { receipt: GoodsReceipt }) {
+ const [showDeleteDialog, setShowDeleteDialog] = useState(false);
+ const { delete: destroy, processing } = useForm({});
+
+ const handleConfirmDelete = () => {
+ // @ts-ignore
+ destroy(route('goods-receipts.destroy', receipt.id), {
+ onSuccess: () => {
+ toast.success("進貨單已成功刪除");
+ setShowDeleteDialog(false);
+ },
+ onError: (errors: any) => toast.error(errors.error || "刪除過程中發生錯誤"),
+ });
+ };
+
+ return (
+
+
+
+
+
+ {/* Delete typically restricted for Goods Receipts, checking permission */}
+
+
+
+
+
+
+ 確認刪除進貨單
+
+ 確定要刪除進貨單 「{receipt.code}」 嗎?
+
+
+ 注意:刪除進貨單將會扣除已入庫的庫存數量!
+
+
+
+
+ 取消
+
+ 確認刪除
+
+
+
+
+
+
+ );
+}
diff --git a/resources/js/Components/Inventory/GoodsReceiptStatusBadge.tsx b/resources/js/Components/Inventory/GoodsReceiptStatusBadge.tsx
new file mode 100644
index 0000000..866cf21
--- /dev/null
+++ b/resources/js/Components/Inventory/GoodsReceiptStatusBadge.tsx
@@ -0,0 +1,46 @@
+import { Badge } from "@/Components/ui/badge";
+
+export type GoodsReceiptStatus = 'processing' | 'completed' | 'cancelled';
+
+export const GOODS_RECEIPT_STATUS_CONFIG: Record = {
+ processing: { label: "處理中", variant: "warning" },
+ completed: { label: "已完成", variant: "success" },
+ cancelled: { label: "已取消", variant: "destructive" },
+};
+
+interface GoodsReceiptStatusBadgeProps {
+ status: string;
+ className?: string;
+}
+
+export default function GoodsReceiptStatusBadge({
+ status,
+ className,
+}: GoodsReceiptStatusBadgeProps) {
+ const config = GOODS_RECEIPT_STATUS_CONFIG[status] || { label: "未知", variant: "outline" };
+
+ // Apply custom styling based on variant mapping if not using standard badge variants
+ let badgeClass = "";
+ switch (config.variant) {
+ case "success":
+ badgeClass = "bg-green-100 text-green-800 hover:bg-green-200 border-green-200";
+ break;
+ case "warning":
+ badgeClass = "bg-yellow-100 text-yellow-800 hover:bg-yellow-200 border-yellow-200";
+ break;
+ case "destructive":
+ badgeClass = "bg-red-100 text-red-800 hover:bg-red-200 border-red-200";
+ break;
+ default:
+ badgeClass = "bg-gray-100 text-gray-800 hover:bg-gray-200 border-gray-200";
+ }
+
+ return (
+
+ {config.label}
+
+ );
+}
diff --git a/resources/js/Components/Inventory/GoodsReceiptTable.tsx b/resources/js/Components/Inventory/GoodsReceiptTable.tsx
new file mode 100644
index 0000000..d2a7cd4
--- /dev/null
+++ b/resources/js/Components/Inventory/GoodsReceiptTable.tsx
@@ -0,0 +1,258 @@
+/**
+ * 進貨單列表表格
+ */
+
+import { useState, useMemo } from "react";
+import { ArrowUpDown, ArrowUp, ArrowDown } from "lucide-react";
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/Components/ui/table";
+import GoodsReceiptActions, { GoodsReceipt } from "./GoodsReceiptActions";
+import GoodsReceiptStatusBadge from "./GoodsReceiptStatusBadge";
+import CopyButton from "@/Components/shared/CopyButton";
+import { formatCurrency, formatDate } from "@/utils/format";
+
+interface GoodsReceiptTableProps {
+ receipts: GoodsReceipt[];
+}
+
+type SortField = "code" | "type" | "warehouse_name" | "vendor_name" | "received_date" | "total_amount" | "status";
+type SortDirection = "asc" | "desc" | null;
+
+export default function GoodsReceiptTable({
+ receipts,
+}: GoodsReceiptTableProps) {
+ const [sortField, setSortField] = useState(null);
+ const [sortDirection, setSortDirection] = useState(null);
+
+ // 處理排序
+ const handleSort = (field: SortField) => {
+ if (sortField === field) {
+ if (sortDirection === "asc") {
+ setSortDirection("desc");
+ } else if (sortDirection === "desc") {
+ setSortDirection(null);
+ setSortField(null);
+ } else {
+ setSortDirection("asc");
+ }
+ } else {
+ setSortField(field);
+ setSortDirection("asc");
+ }
+ };
+
+ // 類型翻譯映射
+ const typeMap: Record = {
+ standard: "標準採購",
+ miscellaneous: "雜項入庫",
+ other: "其他入庫",
+ };
+
+ // 排序後的進貨單列表
+ const sortedReceipts = useMemo(() => {
+ if (!sortField || !sortDirection) {
+ return receipts;
+ }
+
+ return [...receipts].sort((a, b) => {
+ let aValue: string | number;
+ let bValue: string | number;
+
+ switch (sortField) {
+ case "code":
+ aValue = a.code;
+ bValue = b.code;
+ break;
+ case "type":
+ aValue = typeMap[a.status] || a.status; // status here might actually refer to type in existing code logic? Let's use a.type if it exists.
+ // Checking if 'type' is in receipt - based on implementation plan we want it.
+ // Currently GoodsReceipt model HAS type.
+ // @ts-ignore
+ aValue = typeMap[a.type] || a.type || "";
+ // @ts-ignore
+ bValue = typeMap[b.type] || b.type || "";
+ break;
+ case "warehouse_name":
+ aValue = a.warehouse?.name || "";
+ bValue = b.warehouse?.name || "";
+ break;
+ case "vendor_name":
+ aValue = a.vendor?.name || "";
+ bValue = b.vendor?.name || "";
+ break;
+ case "received_date":
+ aValue = a.received_date;
+ bValue = b.received_date;
+ break;
+ case "total_amount":
+ aValue = a.items_sum_total_amount || 0;
+ bValue = b.items_sum_total_amount || 0;
+ break;
+ case "status":
+ aValue = a.status;
+ bValue = b.status;
+ break;
+ default:
+ return 0;
+ }
+
+ if (typeof aValue === "string" && typeof bValue === "string") {
+ return sortDirection === "asc"
+ ? aValue.localeCompare(bValue, "zh-TW")
+ : bValue.localeCompare(aValue, "zh-TW");
+ } else {
+ return sortDirection === "asc"
+ ? (aValue as number) - (bValue as number)
+ : (bValue as number) - (aValue as number);
+ }
+ });
+ }, [receipts, sortField, sortDirection]);
+
+ const SortIcon = ({ field }: { field: SortField }) => {
+ if (sortField !== field) {
+ return ;
+ }
+ if (sortDirection === "asc") {
+ return ;
+ }
+ if (sortDirection === "desc") {
+ return ;
+ }
+ return ;
+ };
+
+ return (
+
+
+
+
+
+ #
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 操作
+
+
+
+ {sortedReceipts.length === 0 ? (
+
+
+ 尚無進貨單
+
+
+ ) : (
+ sortedReceipts.map((receipt, index) => (
+
+
+ {index + 1}
+
+
+
+ {receipt.code}
+
+
+
+
+
+ {/* @ts-ignore */}
+ {typeMap[receipt.type] || receipt.type || "-"}
+
+
+
+
+ {receipt.warehouse?.name || "-"}
+
+
+
+ {receipt.vendor?.name || "-"}
+
+
+ {formatDate(receipt.received_date)}
+
+
+
+ {formatCurrency(receipt.items_sum_total_amount)}
+
+
+
+
+
+
+
+
+
+ ))
+ )}
+
+
+
+
+ );
+}
diff --git a/resources/js/Components/PurchaseOrder/PurchaseOrderStatusBadge.tsx b/resources/js/Components/PurchaseOrder/PurchaseOrderStatusBadge.tsx
index 9a9471a..7a017a0 100644
--- a/resources/js/Components/PurchaseOrder/PurchaseOrderStatusBadge.tsx
+++ b/resources/js/Components/PurchaseOrder/PurchaseOrderStatusBadge.tsx
@@ -4,6 +4,7 @@
import { Badge } from "@/Components/ui/badge";
import { PurchaseOrderStatus } from "@/types/purchase-order";
+import { STATUS_CONFIG } from "@/constants/purchase-order";
interface PurchaseOrderStatusBadgeProps {
status: PurchaseOrderStatus;
@@ -14,35 +15,12 @@ export default function PurchaseOrderStatusBadge({
status,
className,
}: PurchaseOrderStatusBadgeProps) {
- const getStatusConfig = (status: PurchaseOrderStatus) => {
- switch (status) {
- case "draft":
- return { label: "草稿", className: "bg-gray-100 text-gray-700 border-gray-200" };
- case "pending":
- return { label: "待審核", className: "bg-blue-100 text-blue-700 border-blue-200" };
- case "processing":
- return { label: "處理中", className: "bg-yellow-100 text-yellow-700 border-yellow-200" };
- case "shipping":
- return { label: "運送中", className: "bg-purple-100 text-purple-700 border-purple-200" };
- case "confirming":
- return { label: "待確認", className: "bg-orange-100 text-orange-700 border-orange-200" };
- case "completed":
- return { label: "已完成", className: "bg-green-100 text-green-700 border-green-200" };
- case "cancelled":
- return { label: "已取消", className: "bg-red-100 text-red-700 border-red-200" };
- case "partial":
- return { label: "部分進貨", className: "bg-blue-50 text-blue-600 border-blue-100" };
- default:
- return { label: "未知", className: "bg-gray-100 text-gray-700 border-gray-200" };
- }
- };
-
- const config = getStatusConfig(status);
+ const config = STATUS_CONFIG[status] || { label: "未知", variant: "outline" };
return (
{config.label}
diff --git a/resources/js/Components/PurchaseOrder/StatusProgressBar.tsx b/resources/js/Components/PurchaseOrder/StatusProgressBar.tsx
index 53be61d..01f7f89 100644
--- a/resources/js/Components/PurchaseOrder/StatusProgressBar.tsx
+++ b/resources/js/Components/PurchaseOrder/StatusProgressBar.tsx
@@ -10,13 +10,13 @@ interface StatusProgressBarProps {
}
// 流程步驟定義
-const FLOW_STEPS: { key: PurchaseOrderStatus | "approved"; label: string }[] = [
+const FLOW_STEPS: { key: PurchaseOrderStatus; label: string }[] = [
{ key: "draft", label: "草稿" },
- { key: "pending", label: "待審核" },
- { key: "processing", label: "處理中" },
- { key: "shipping", label: "運送中" },
- { key: "confirming", label: "待確認" },
- { key: "completed", label: "已完成" },
+ { key: "pending", label: "簽核中" },
+ { key: "approved", label: "已核准" },
+ { key: "partial", label: "部分收貨" },
+ { key: "completed", label: "全數收貨" },
+ { key: "closed", label: "已結案" },
];
export function StatusProgressBar({ currentStatus }: StatusProgressBarProps) {
@@ -82,7 +82,7 @@ export function StatusProgressBar({ currentStatus }: StatusProgressBarProps) {
: "text-gray-400"
}`}
>
- {isRejectedAtThisStep ? "已取消" : step.label}
+ {isRejectedAtThisStep ? "已作廢" : step.label}
diff --git a/resources/js/Pages/Inventory/GoodsReceipt/Create.tsx b/resources/js/Pages/Inventory/GoodsReceipt/Create.tsx
index acb883f..cf49f88 100644
--- a/resources/js/Pages/Inventory/GoodsReceipt/Create.tsx
+++ b/resources/js/Pages/Inventory/GoodsReceipt/Create.tsx
@@ -1,5 +1,5 @@
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout';
-import { Head, useForm } from '@inertiajs/react';
+import { Head, useForm, Link } from '@inertiajs/react';
import { Button } from '@/Components/ui/button';
import { Input } from '@/Components/ui/input';
import { Label } from '@/Components/ui/label';
@@ -10,7 +10,8 @@ import {
SelectTrigger,
SelectValue,
} from '@/Components/ui/select';
-import { useState } from 'react';
+import { SearchableSelect } from '@/Components/ui/searchable-select';
+import React, { useState, useEffect } from 'react';
import {
Table,
TableBody,
@@ -19,17 +20,9 @@ import {
TableHeader,
TableRow,
} from '@/Components/ui/table';
-import {
- AlertDialog,
- AlertDialogAction,
- AlertDialogCancel,
- AlertDialogContent,
- AlertDialogDescription,
- AlertDialogFooter,
- AlertDialogHeader,
- AlertDialogTitle,
- AlertDialogTrigger,
-} from "@/Components/ui/alert-dialog";
+
+import { Badge } from "@/Components/ui/badge";
+
import {
Search,
@@ -40,35 +33,65 @@ import {
Package
} from 'lucide-react';
import axios from 'axios';
+import { PurchaseOrderStatus } from '@/types/purchase-order';
+import { STATUS_CONFIG } from '@/constants/purchase-order';
-interface POItem {
+
+
+interface BatchItem {
+ inventoryId: string;
+ batchNumber: string;
+ originCountry: string;
+ expiryDate: string | null;
+ quantity: number;
+}
+
+// 待進貨採購單 Item 介面
+interface PendingPOItem {
id: number;
product_id: number;
- product: { name: string; sku: string };
+ product_name: string;
+ product_code: string;
+ unit: string;
quantity: number;
received_quantity: number;
+ remaining: number;
unit_price: number;
+ batchMode?: 'existing' | 'new';
+ originCountry?: string; // For new batch generation
}
-interface PO {
+// 待進貨採購單介面
+interface PendingPO {
id: number;
code: string;
+ status: PurchaseOrderStatus;
vendor_id: number;
- vendor: { id: number; name: string };
+ vendor_name: string;
warehouse_id: number | null;
- items: POItem[];
+ order_date: string;
+ items: PendingPOItem[];
}
-export default function GoodsReceiptCreate({ warehouses }: { warehouses: any[] }) {
- const [poSearch, setPoSearch] = useState('');
- const [foundPOs, setFoundPOs] = useState([]);
- const [selectedPO, setSelectedPO] = useState(null);
+// 廠商介面
+interface Vendor {
+ id: number;
+ name: string;
+ code: string;
+}
+
+interface Props {
+ warehouses: { id: number; name: string; type: string }[];
+ pendingPurchaseOrders: PendingPO[];
+ vendors: Vendor[];
+}
+
+export default function GoodsReceiptCreate({ warehouses, pendingPurchaseOrders, vendors }: Props) {
+ const [selectedPO, setSelectedPO] = useState(null);
+ const [selectedVendor, setSelectedVendor] = useState(null);
const [isSearching, setIsSearching] = useState(false);
- // Manual Selection States
- const [vendorSearch, setVendorSearch] = useState('');
- const [foundVendors, setFoundVendors] = useState([]);
- const [selectedVendor, setSelectedVendor] = useState(null);
+ // Manual Product Search States
const [productSearch, setProductSearch] = useState('');
const [foundProducts, setFoundProducts] = useState([]);
@@ -82,36 +105,7 @@ export default function GoodsReceiptCreate({ warehouses }: { warehouses: any[] }
items: [] as any[],
});
- const searchPO = async () => {
- if (!poSearch) return;
- setIsSearching(true);
- try {
- const response = await axios.get(route('goods-receipts.search-pos'), {
- params: { query: poSearch },
- });
- setFoundPOs(response.data);
- } catch (error) {
- console.error('Failed to search POs', error);
- } finally {
- setIsSearching(false);
- }
- };
-
- const searchVendors = async () => {
- if (!vendorSearch) return;
- setIsSearching(true);
- try {
- const response = await axios.get(route('goods-receipts.search-vendors'), {
- params: { query: vendorSearch },
- });
- setFoundVendors(response.data);
- } catch (error) {
- console.error('Failed to search vendors', error);
- } finally {
- setIsSearching(false);
- }
- };
-
+ // 搜尋商品 API(用於雜項入庫/其他類型)
const searchProducts = async () => {
if (!productSearch) return;
setIsSearching(true);
@@ -127,24 +121,25 @@ export default function GoodsReceiptCreate({ warehouses }: { warehouses: any[] }
}
};
- const handleSelectPO = (po: PO) => {
+ // 選擇採購單
+ const handleSelectPO = (po: PendingPO) => {
setSelectedPO(po);
- setSelectedVendor(po.vendor);
- const pendingItems = po.items.map((item) => {
- const remaining = item.quantity - item.received_quantity;
- return {
- product_id: item.product_id,
- purchase_order_item_id: item.id,
- product_name: item.product.name,
- sku: item.product.sku,
- quantity_ordered: item.quantity,
- quantity_received_so_far: item.received_quantity,
- quantity_received: remaining > 0 ? remaining : 0,
- unit_price: item.unit_price,
- batch_number: '',
- expiry_date: '',
- };
- });
+ // 將採購單項目轉換為進貨單項目,預填剩餘可收貨量
+ const pendingItems = po.items.map((item) => ({
+ product_id: item.product_id,
+ purchase_order_item_id: item.id,
+ product_name: item.product_name,
+ sku: item.product_code,
+ unit: item.unit,
+ quantity_ordered: item.quantity,
+ quantity_received_so_far: item.received_quantity,
+ quantity_received: item.remaining, // 預填剩餘量
+ unit_price: item.unit_price,
+ batch_number: '',
+ batchMode: 'new',
+ originCountry: 'TW',
+ expiry_date: '',
+ }));
setData((prev) => ({
...prev,
@@ -153,13 +148,15 @@ export default function GoodsReceiptCreate({ warehouses }: { warehouses: any[] }
warehouse_id: po.warehouse_id ? po.warehouse_id.toString() : prev.warehouse_id,
items: pendingItems,
}));
- setFoundPOs([]);
};
- const handleSelectVendor = (vendor: any) => {
- setSelectedVendor(vendor);
- setData('vendor_id', vendor.id.toString());
- setFoundVendors([]);
+ // 選擇廠商(雜項入庫/其他)
+ const handleSelectVendor = (vendorId: string) => {
+ const vendor = vendors.find(v => v.id.toString() === vendorId);
+ if (vendor) {
+ setSelectedVendor(vendor);
+ setData('vendor_id', vendor.id.toString());
+ }
};
const handleAddProduct = (product: any) => {
@@ -170,6 +167,8 @@ export default function GoodsReceiptCreate({ warehouses }: { warehouses: any[] }
quantity_received: 0,
unit_price: product.price || 0,
batch_number: '',
+ batchMode: 'new',
+ originCountry: 'TW',
expiry_date: '',
};
setData('items', [...data.items, newItem]);
@@ -189,11 +188,118 @@ export default function GoodsReceiptCreate({ warehouses }: { warehouses: any[] }
setData('items', newItems);
};
+ // Generate batch preview (Added)
+ const getBatchPreview = (productId: number, productCode: string, country: string, dateStr: string) => {
+ if (!productCode || !productId) return "--";
+ try {
+ const datePart = dateStr.includes('T') ? dateStr.split('T')[0] : dateStr;
+ const [yyyy, mm, dd] = datePart.split('-');
+ const dateFormatted = `${yyyy}${mm}${dd}`;
+
+ const seqKey = `${productId}-${country}-${datePart}`;
+ // Handle sequence. Note: nextSequences values are numbers.
+ const seq = nextSequences[seqKey]?.toString().padStart(2, '0') || "01";
+
+ return `${productCode}-${country}-${dateFormatted}-${seq}`;
+ } catch (e) {
+ return "--";
+ }
+ };
+
+ // Batch management
+ const [batchesCache, setBatchesCache] = useState>({});
+ const [nextSequences, setNextSequences] = useState>({});
+
+ // Fetch batches and sequence for a product
+ const fetchProductBatches = async (productId: number, country: string = 'TW', dateStr: string = '') => {
+ if (!data.warehouse_id) return;
+ const cacheKey = `${productId}-${data.warehouse_id}`;
+
+ try {
+ const today = new Date().toISOString().split('T')[0];
+ const targetDate = dateStr || data.received_date || today;
+
+ // Adjust API endpoint to match AddInventory logic
+ // Assuming GoodsReceiptController or existing WarehouseController can handle this.
+ // Using the same endpoint as AddInventory: /api/warehouses/{id}/inventory/batches/{productId}
+ const response = await axios.get(
+ `/api/warehouses/${data.warehouse_id}/inventory/batches/${productId}`,
+ {
+ params: {
+ origin_country: country,
+ arrivalDate: targetDate
+ }
+ }
+ );
+
+ if (response.data) {
+ // Update existing batches list
+ if (response.data.batches) {
+ setBatchesCache(prev => ({
+ ...prev,
+ [cacheKey]: response.data.batches
+ }));
+ }
+
+ // Update next sequence for new batch generation
+ if (response.data.nextSequence !== undefined) {
+ const seqKey = `${productId}-${country}-${targetDate}`;
+ setNextSequences(prev => ({
+ ...prev,
+ [seqKey]: parseInt(response.data.nextSequence)
+ }));
+ }
+ }
+ } catch (error) {
+ console.error("Failed to fetch batches", error);
+ }
+ };
+
+ // Trigger batch fetch when relevant fields change
+ useEffect(() => {
+ data.items.forEach(item => {
+ if (item.product_id && data.warehouse_id) {
+ const country = item.originCountry || 'TW';
+ const date = data.received_date;
+ fetchProductBatches(item.product_id, country, date);
+ }
+ });
+ }, [data.items.length, data.warehouse_id, data.received_date, JSON.stringify(data.items.map(i => i.originCountry))]);
+
+ useEffect(() => {
+ data.items.forEach((item, index) => {
+ if (item.batchMode === 'new' && item.originCountry && data.received_date) {
+ const country = item.originCountry;
+ // Use date from form or today
+ const dateStr = data.received_date || new Date().toISOString().split('T')[0];
+ const seqKey = `${item.product_id}-${country}-${dateStr}`;
+ const seq = nextSequences[seqKey]?.toString().padStart(3, '0') || '001';
+
+ // Only generate if we have a sequence (or default)
+ // Note: fetch might not have returned yet, so seq might be default 001 until fetch updates nextSequences
+
+ const datePart = dateStr.replace(/-/g, '');
+ const generatedBatch = `${item.sku}-${country}-${datePart}-${seq}`;
+
+ if (item.batch_number !== generatedBatch) {
+ // Update WITHOUT triggering re-render loop
+ // Need a way to update item silently or check condition carefully
+ // Using setBatchNumber might trigger this effect again but value will be same.
+ const newItems = [...data.items];
+ newItems[index].batch_number = generatedBatch;
+ setData('items', newItems);
+ }
+ }
+ });
+ }, [nextSequences, JSON.stringify(data.items.map(i => ({ m: i.batchMode, c: i.originCountry, s: i.sku, p: i.product_id }))), data.received_date]);
+
const submit = (e: React.FormEvent) => {
e.preventDefault();
post(route('goods-receipts.store'));
};
+
+
return (
{/* Header */}
-
+
+
+
@@ -262,11 +371,11 @@ export default function GoodsReceiptCreate({ warehouses }: { warehouses: any[] }
{/* Step 1: Source Selection */}
-
+
{(data.type === 'standard' ? selectedPO : selectedVendor) ? '✓' : '1'}
-
+
{data.type === 'standard' ? '選擇來源採購單' : '選擇供應商'}
@@ -275,41 +384,40 @@ export default function GoodsReceiptCreate({ warehouses }: { warehouses: any[] }
{data.type === 'standard' ? (
!selectedPO ? (
-
-
-
- setPoSearch(e.target.value)}
- onKeyDown={(e) => e.key === 'Enter' && searchPO()}
- className="h-9"
- />
-
-
-
+
- {foundPOs.length > 0 && (
+ {pendingPurchaseOrders.length === 0 ? (
+
+ 目前沒有待進貨的採購單
+
+ ) : (
- 單號
+ 採購單號
供應商
+ 狀態
+ 待收項目
操作
- {foundPOs.map((po) => (
-
+ {pendingPurchaseOrders.map((po) => (
+
{po.code}
- {po.vendor?.name}
+ {po.vendor_name}
-
+
+ {po.items.length} 項
+
+
+
@@ -328,7 +436,11 @@ export default function GoodsReceiptCreate({ warehouses }: { warehouses: any[] }
供應商
- {selectedPO.vendor?.name}
+ {selectedPO.vendor_name}
+
+
+ 待收項目
+ {selectedPO.items.length} 項
-
+
);
}
diff --git a/resources/js/Pages/Inventory/GoodsReceipt/Index.tsx b/resources/js/Pages/Inventory/GoodsReceipt/Index.tsx
index 4d82a08..87148fb 100644
--- a/resources/js/Pages/Inventory/GoodsReceipt/Index.tsx
+++ b/resources/js/Pages/Inventory/GoodsReceipt/Index.tsx
@@ -1,29 +1,122 @@
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout';
import { Head, Link, router } from '@inertiajs/react';
import { Button } from '@/Components/ui/button';
-import { Plus, Search, FileText } from 'lucide-react';
+import { Plus, Search, FileText, RotateCcw, Calendar, ChevronDown, ChevronUp } from 'lucide-react';
import { Input } from '@/Components/ui/input';
-import {
- Table,
- TableBody,
- TableCell,
- TableHead,
- TableHeader,
- TableRow,
-} from '@/Components/ui/table';
-import { Badge } from '@/Components/ui/badge';
+import { Label } from '@/Components/ui/label';
+import { SearchableSelect } from '@/Components/ui/searchable-select';
import Pagination from '@/Components/shared/Pagination';
-import { useState } from 'react';
+import { useState, useEffect } from 'react';
import { Can } from '@/Components/Permission/Can';
+import { getDateRange } from '@/utils/format';
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from '@/Components/ui/select';
+import GoodsReceiptTable from '@/Components/Inventory/GoodsReceiptTable';
-export default function GoodsReceiptIndex({ receipts, filters }: any) {
+interface Warehouse {
+ id: number;
+ name: string;
+ type: string;
+}
+
+interface Filters {
+ search?: string;
+ status?: string;
+ warehouse_id?: string;
+ date_start?: string;
+ date_end?: string;
+ per_page?: string;
+}
+
+interface Props {
+ receipts: any;
+ filters: Filters;
+ warehouses: Warehouse[];
+}
+
+export default function GoodsReceiptIndex({ receipts, filters, warehouses }: Props) {
const [search, setSearch] = useState(filters.search || '');
+ const [status, setStatus] = useState(filters.status || 'all');
+ const [warehouseId, setWarehouseId] = useState(filters.warehouse_id || 'all');
+ const [dateStart, setDateStart] = useState(filters.date_start || '');
+ const [dateEnd, setDateEnd] = useState(filters.date_end || '');
+ const [perPage, setPerPage] = useState(filters.per_page || '10');
+ const [dateRangeType, setDateRangeType] = useState('custom');
- const handleSearch = (e: React.FormEvent) => {
- e.preventDefault();
- router.get(route('goods-receipts.index'), { search }, { preserveState: true });
+ // Advanced Filter Toggle
+ const [showAdvanced, setShowAdvanced] = useState(
+ !!(filters.date_start || filters.date_end)
+ );
+
+ // Sync filters from props
+ useEffect(() => {
+ setSearch(filters.search || '');
+ setStatus(filters.status || 'all');
+ setWarehouseId(filters.warehouse_id || 'all');
+ setDateStart(filters.date_start || '');
+ setDateEnd(filters.date_end || '');
+ setPerPage(filters.per_page || '10');
+ }, [filters]);
+
+ const handleFilter = () => {
+ router.get(route('goods-receipts.index'), {
+ search,
+ status: status !== 'all' ? status : undefined,
+ warehouse_id: warehouseId !== 'all' ? warehouseId : undefined,
+ date_start: dateStart || undefined,
+ date_end: dateEnd || undefined,
+ per_page: perPage,
+ }, { preserveState: true, replace: true });
};
+ const handleReset = () => {
+ setSearch('');
+ setStatus('all');
+ setWarehouseId('all');
+ setDateStart('');
+ setDateEnd('');
+ setDateRangeType('custom');
+ setPerPage('10');
+ router.get(route('goods-receipts.index'), {}, { preserveState: false });
+ };
+
+ const handleDateRangeChange = (type: string) => {
+ setDateRangeType(type);
+ if (type === 'custom') return;
+
+ const { start, end } = getDateRange(type);
+ setDateStart(start);
+ setDateEnd(end);
+ };
+
+ const handlePerPageChange = (value: string) => {
+ setPerPage(value);
+ router.get(route('goods-receipts.index'), {
+ search,
+ status: status !== 'all' ? status : undefined,
+ warehouse_id: warehouseId !== 'all' ? warehouseId : undefined,
+ date_start: dateStart || undefined,
+ date_end: dateEnd || undefined,
+ per_page: value,
+ }, { preserveState: true, preserveScroll: true, replace: true });
+ };
+
+ const statusOptions = [
+ { label: '全部狀態', value: 'all' },
+ { label: '已完成', value: 'completed' },
+ { label: '處理中', value: 'processing' },
+ ];
+
+ const warehouseOptions = [
+ { label: '全部倉庫', value: 'all' },
+ ...warehouses.map(w => ({ label: w.name, value: w.id.toString() }))
+ ];
+
return (
{/* Filter Bar */}
-