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} - @@ -328,7 +436,11 @@ export default function GoodsReceiptCreate({ warehouses }: { warehouses: any[] }
供應商 - {selectedPO.vendor?.name} + {selectedPO.vendor_name} +
+
+ 待收項目 + {selectedPO.items.length} 項
+
+ + ({ + label: `${v.name} (${v.code})`, + value: v.id.toString() + }))} + placeholder="選擇供應商..." + searchPlaceholder="搜尋供應商..." + className="h-9 w-full max-w-md" + />
- - {foundVendors.length > 0 && ( -
-
- - - 名稱 - 代號 - 操作 - - - - {foundVendors.map((v) => ( - - {v.name} - {v.code} - - - - - ))} - -
+ {vendors.length === 0 && ( +
+ 目前沒有可選擇的供應商
)}
@@ -408,8 +496,8 @@ export default function GoodsReceiptCreate({ warehouses }: { warehouses: any[] } {((data.type === 'standard' && selectedPO) || (data.type !== 'standard' && selectedVendor)) && (
-
2
-

進貨資訊與明細

+
2
+

進貨資訊與明細

@@ -491,125 +579,163 @@ export default function GoodsReceiptCreate({ warehouses }: { warehouses: any[] } )}
-
- - - - 商品資訊 - - {data.type === 'standard' ? '採購量 / 已收' : '規格'} - - 單價 - 收貨量 * - 批號 - 效期 - 小計 - {data.type !== 'standard' && } - - - - {data.items.length === 0 ? ( - - - 尚無明細,請搜尋商品加入。 - - - ) : ( - data.items.map((item, index) => { - const errorKey = `items.${index}.quantity_received` as keyof typeof errors; - return ( - - -
{item.product_name}
-
{item.sku}
-
- - {data.type === 'standard' - ? `${item.quantity_ordered} / ${item.quantity_received_so_far}` - : '一般'} - - - updateItem(index, 'unit_price', e.target.value)} - className="h-8 text-right w-20 ml-auto" - disabled={data.type === 'standard'} - /> - - - updateItem(index, 'quantity_received', e.target.value)} - className={`h-8 w-20 ${errors[errorKey] ? 'border-red-500' : ''}`} - /> - {errors[errorKey] && ( -

{errors[errorKey] as string}

- )} -
- - updateItem(index, 'batch_number', e.target.value)} - placeholder="選填" - className="h-8" - /> - - - updateItem(index, 'expiry_date', e.target.value)} - className="h-8" - /> - - - ${(parseFloat(item.quantity_received || 0) * parseFloat(item.unit_price)).toLocaleString()} - - {data.type !== 'standard' && ( - - - + {/* Calculated Totals for usage in Table Footer or Summary */} + {(() => { + const subTotal = data.items.reduce((acc, item) => { + const qty = parseFloat(item.quantity_received) || 0; + const price = parseFloat(item.unit_price) || 0; + return acc + (qty * price); + }, 0); + const taxAmount = Math.round(subTotal * 0.05); + const grandTotal = subTotal + taxAmount; + + return ( + <> +
+
+ + + 商品資訊 + 總數量 + 待收貨 + 本次收貨 * + 批號設定 * + 效期 + 小計 + + + + + {data.items.length === 0 ? ( + + + 尚無明細,請搜尋商品加入。 + + + ) : ( + data.items.map((item, index) => { + const errorKey = `items.${index}.quantity_received` as keyof typeof errors; + const itemTotal = (parseFloat(item.quantity_received || 0) * parseFloat(item.unit_price || 0)); + + return ( + + {/* Product Info */} + +
+ {item.product_name} + {item.sku} +
+
+ + {/* Total Quantity */} + + + {Math.round(item.quantity_ordered)} + + + + {/* Remaining */} + + + {Math.round(item.quantity_ordered - item.quantity_received_so_far)} + + + + {/* Received Quantity */} + + updateItem(index, 'quantity_received', e.target.value)} + className={`w-full ${(errors as any)[errorKey] ? 'border-red-500' : ''}`} + /> + {(errors as any)[errorKey] && ( +

{(errors as any)[errorKey]}

+ )} +
+ + {/* Batch Settings */} + +
+ updateItem(index, 'originCountry', e.target.value.toUpperCase().slice(0, 2))} + placeholder="產地" + maxLength={2} + className="w-16 text-center px-1" + /> +
+ {getBatchPreview(item.product_id, item.sku, item.originCountry || 'TW', data.received_date)} +
+
+
+ + {/* Expiry Date */} + +
+ + updateItem(index, 'expiry_date', e.target.value)} + className={`pl-9 ${item.batchMode === 'existing' ? 'bg-gray-50' : ''}`} + disabled={item.batchMode === 'existing'} + /> +
+
+ + {/* Subtotal */} + + ${itemTotal.toLocaleString()} + + + {/* Actions */} + - - - - 確定要移除此商品嗎? - - 此動作將從清單中移除該商品,您之後需要重新搜尋才能再次加入。 - - - - 取消 - removeItem(index)} - className="bg-red-600 hover:bg-red-700" - > - 確定移除 - - - - - - )} -
- ) - }) - )} -
-
-
+ + + ); + }) + )} + + +
+ +
+
+
+ 小計 + ${subTotal.toLocaleString()} +
+ +
+ 稅額 (5%) + ${taxAmount.toLocaleString()} +
+ +
+ +
+ 總計金額 + + ${grandTotal.toLocaleString()} + +
+
+
+ + ); + })()}
@@ -632,6 +758,6 @@ export default function GoodsReceiptCreate({ warehouses }: { warehouses: any[] }

-
+ ); } 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 */} -
-
-
- -
+
+ {/* Row 1: Search, Status, Warehouse */} +
+
+ +
+ setSearch(e.target.value)} - className="w-64 h-9" + className="pl-10 h-9 block" + onKeyDown={(e) => e.key === 'Enter' && handleFilter()} /> -
- + +
+ + +
+ +
+ + 10} + /> +
+
+ + {/* Row 2: Date Filters (Collapsible) */} + {showAdvanced && ( +
+
+ +
+ {[ + { label: "今日", value: "today" }, + { label: "昨日", value: "yesterday" }, + { label: "本週", value: "this_week" }, + { label: "本月", value: "this_month" }, + { label: "上月", value: "last_month" }, + ].map((opt) => ( + + ))} +
+
+ +
+
+
+ +
+ + { + setDateStart(e.target.value); + setDateRangeType('custom'); + }} + className="pl-9 block w-full h-9 bg-white" + /> +
+
+
+ +
+ + { + setDateEnd(e.target.value); + setDateRangeType('custom'); + }} + className="pl-9 block w-full h-9 bg-white text-left" + /> +
+
+
+
+
+ )} + +
+ + + +
{/* Table Section */} -
- - - - 單號 - 倉庫 - 供應商ID - 進貨日期 - 狀態 - 操作 - - - - {receipts.data.length === 0 ? ( - - - 尚無進貨紀錄 - - - ) : ( - receipts.data.map((receipt: any) => ( - - {receipt.code} - {receipt.warehouse?.name} - {receipt.vendor_id} - {receipt.received_date} - - - {receipt.status} - - - -
- - - -
-
-
- )) - )} -
-
-
+ -
+ {/* Pagination */} +
+
+ 每頁顯示 + + +
diff --git a/resources/js/Pages/Inventory/GoodsReceipt/Show.tsx b/resources/js/Pages/Inventory/GoodsReceipt/Show.tsx new file mode 100644 index 0000000..dabe172 --- /dev/null +++ b/resources/js/Pages/Inventory/GoodsReceipt/Show.tsx @@ -0,0 +1,221 @@ +/** + * 查看進貨單詳情頁面 + */ + +import { ArrowLeft, Package } from "lucide-react"; +import { Button } from "@/Components/ui/button"; +import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout"; +import { Head, Link } from "@inertiajs/react"; +import GoodsReceiptStatusBadge from "@/Components/Inventory/GoodsReceiptStatusBadge"; +import CopyButton from "@/Components/shared/CopyButton"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/Components/ui/table"; +import { formatCurrency, formatDate, formatDateTime } from "@/utils/format"; +import { getShowBreadcrumbs } from "@/utils/breadcrumb"; + +interface GoodsReceiptItem { + id: number; + product_id: number; + product: { + id: number; + name: string; + code: string; + baseUnit?: { + name: string; + }; + }; + quantity_received: string | number; + unit_price: string | number; + total_amount: string | number; + batch_number?: string; + expiry_date?: string; +} + +interface GoodsReceipt { + id: number; + code: string; + type: string; + received_date: string; + status: string; + remark?: string; + warehouse?: { + name: string; + }; + vendor?: { + name: string; + }; + items: GoodsReceiptItem[]; + items_sum_total_amount: number; + created_at: string; +} + +interface Props { + receipt: GoodsReceipt; +} + +export default function ViewGoodsReceiptPage({ receipt }: Props) { + const typeMap: Record = { + standard: "標準採購進貨", + miscellaneous: "雜項入庫", + other: "其他入庫", + }; + + return ( + + +
+ {/* Header */} +
+ + + + +
+
+

+ + 查看進貨單 +

+

單號:{receipt.code}

+
+
+ +
+
+
+ +
+ {/* 基本資訊卡片 */} +
+

基本資訊

+
+
+ 進貨單編號 +
+ {receipt.code} + +
+
+
+ 入庫類型 + {typeMap[receipt.type] || receipt.type} +
+
+ 倉庫 + {receipt.warehouse?.name || "-"} +
+
+ 供應商 + {receipt.vendor?.name || "-"} +
+
+ 進貨日期 + {formatDate(receipt.received_date)} +
+
+ 建立時間 + {formatDateTime(receipt.created_at)} +
+
+ {receipt.remark && ( +
+ 備註 +

+ {receipt.remark} +

+
+ )} +
+ + {/* 品項清單卡片 */} +
+
+

進貨品項清單

+
+
+
+ + + + # + 商品名稱 + 進貨數量 + 單位 + 單價 + 小計 + 批號 + 效期 + + + + {receipt.items.length === 0 ? ( + + + 無品項資料 + + + ) : ( + receipt.items.map((item, index) => ( + + {index + 1} + +
+ {item.product.name} + {item.product.code} +
+
+ + {Number(item.quantity_received).toLocaleString()} + + + {item.product.baseUnit?.name || "個"} + + + {formatCurrency(Number(item.unit_price))} + + + {formatCurrency(Number(item.total_amount))} + + + {item.batch_number || "-"} + + + {item.expiry_date ? formatDate(item.expiry_date) : "-"} + +
+ )) + )} +
+
+
+ + {/* 總計 */} +
+
+
+ 總計金額 + + {formatCurrency(receipt.items_sum_total_amount)} + +
+
+
+
+
+
+
+
+ ); +} diff --git a/resources/js/Pages/PurchaseOrder/Index.tsx b/resources/js/Pages/PurchaseOrder/Index.tsx index e8edc87..104abcb 100644 --- a/resources/js/Pages/PurchaseOrder/Index.tsx +++ b/resources/js/Pages/PurchaseOrder/Index.tsx @@ -23,6 +23,7 @@ import { SelectTrigger, SelectValue, } from "@/Components/ui/select"; +import { STATUS_OPTIONS } from "@/constants/purchase-order"; interface Props { orders: { @@ -176,14 +177,11 @@ export default function PurchaseOrderIndex({ orders, filters, warehouses }: Prop 全部狀態 - 草稿 - 待審核 - 處理中 - 運送中 - 待確認 - 已完成 - 部分進貨 - 已取消 + {STATUS_OPTIONS.map((option) => ( + + {option.label} + + ))}
diff --git a/resources/js/Pages/PurchaseOrder/Show.tsx b/resources/js/Pages/PurchaseOrder/Show.tsx index 42cf5cd..fa6c60b 100644 --- a/resources/js/Pages/PurchaseOrder/Show.tsx +++ b/resources/js/Pages/PurchaseOrder/Show.tsx @@ -147,20 +147,26 @@ export default function ViewPurchaseOrderPage({ order }: Props) { items={order.items} isReadOnly={true} /> -
-
- 小計 - {formatCurrency(order.totalAmount)} -
-
- 稅額 - {formatCurrency(order.tax_amount || 0)} -
-
- 總計 - - {formatCurrency(order.grand_total || (order.totalAmount + (order.tax_amount || 0)))} - +
+
+
+ 小計 + {formatCurrency(order.totalAmount)} +
+ +
+ 稅額 + {formatCurrency(order.taxAmount || 0)} +
+ +
+ +
+ 總計 (含稅) + + {formatCurrency(order.grandTotal || (order.totalAmount + (order.taxAmount || 0)))} + +
diff --git a/resources/js/constants/purchase-order.ts b/resources/js/constants/purchase-order.ts index 315a5b6..f37c2e1 100644 --- a/resources/js/constants/purchase-order.ts +++ b/resources/js/constants/purchase-order.ts @@ -10,13 +10,12 @@ export const STATUS_CONFIG: Record< { label: string; variant: "default" | "secondary" | "destructive" | "outline" } > = { draft: { label: "草稿", variant: "outline" }, - pending: { label: "待審核", variant: "outline" }, - processing: { label: "處理中", variant: "outline" }, - shipping: { label: "運送中", variant: "outline" }, - confirming: { label: "待確認", variant: "outline" }, - completed: { label: "已完成", variant: "outline" }, - cancelled: { label: "已取消", variant: "outline" }, - partial: { label: "部分進貨", variant: "secondary" }, + pending: { label: "簽核中", variant: "outline" }, + approved: { label: "已核准", variant: "default" }, + partial: { label: "部分收貨", variant: "secondary" }, + completed: { label: "全數收貨", variant: "outline" }, + closed: { label: "已結案", variant: "outline" }, + cancelled: { label: "已作廢", variant: "destructive" }, }; export const STATUS_OPTIONS = Object.entries(STATUS_CONFIG).map(([value, config]) => ({ diff --git a/resources/js/types/goods-receipt.ts b/resources/js/types/goods-receipt.ts new file mode 100644 index 0000000..d515937 --- /dev/null +++ b/resources/js/types/goods-receipt.ts @@ -0,0 +1,27 @@ +export interface GoodsReceipt { + id: number; + code: string; + warehouse_id: number; + warehouse?: { + id: number; + name: string; + }; + vendor_id?: number; + vendor?: { + id: number; + name: string; + }; + purchase_order_id?: number; + purchase_order?: { + code: string; // If loaded + }; + received_date: string; + status: 'completed' | 'processing' | 'cancelled'; + remarks?: string; + items_sum_total_amount?: number; // Calculated field + created_at: string; + updated_at: string; + user?: { + name: string; + }; +} diff --git a/resources/js/types/purchase-order.ts b/resources/js/types/purchase-order.ts index 0b21dc9..2f1056d 100644 --- a/resources/js/types/purchase-order.ts +++ b/resources/js/types/purchase-order.ts @@ -4,14 +4,12 @@ export type PurchaseOrderStatus = | "draft" // 草稿 - | "pending" // 待審核 - | "processing" // 處理中 - | "shipping" // 運送中 - | "confirming" // 待確認 - | "completed" // 已完成 - | "completed" // 已完成 - | "cancelled" // 已取消 - | "partial"; // 部分進貨 + | "pending" // 簽核中 + | "approved" // 已核准 + | "partial" // 部分收貨 + | "completed" // 全數收貨 + | "closed" // 已結案 + | "cancelled"; // 已作廢