feat: 統一進貨單 UI、修復庫存異動紀錄與廠商詳情顯示報錯
This commit is contained in:
@@ -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}%");
|
||||
}
|
||||
|
||||
$receipts = $query->orderBy('created_at', 'desc')
|
||||
->paginate(10)
|
||||
->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.
|
||||
// 狀態篩選
|
||||
if ($request->filled('status') && $request->input('status') !== 'all') {
|
||||
$query->where('status', $request->input('status'));
|
||||
}
|
||||
|
||||
// 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.
|
||||
// 倉庫篩選
|
||||
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($perPage)
|
||||
->withQueryString();
|
||||
|
||||
// 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
|
||||
];
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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', [
|
||||
|
||||
@@ -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']);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
// Update old statuses to 'approved'
|
||||
DB::table('purchase_orders')
|
||||
->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.
|
||||
}
|
||||
};
|
||||
101
resources/js/Components/Inventory/GoodsReceiptActions.tsx
Normal file
101
resources/js/Components/Inventory/GoodsReceiptActions.tsx
Normal file
@@ -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 (
|
||||
<div className="flex justify-center gap-2">
|
||||
<Link href={route('goods-receipts.show', receipt.id)}>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="button-outlined-primary"
|
||||
title="查看詳情"
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
{/* Delete typically restricted for Goods Receipts, checking permission */}
|
||||
<Can permission="goods_receipts.delete">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="button-outlined-error"
|
||||
title="刪除"
|
||||
onClick={() => setShowDeleteDialog(true)}
|
||||
disabled={processing}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>確認刪除進貨單</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
確定要刪除進貨單 「{receipt.code}」 嗎?
|
||||
<br />
|
||||
<span className="text-red-500 font-bold mt-2 block">
|
||||
注意:刪除進貨單將會扣除已入庫的庫存數量!
|
||||
</span>
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel className="button-outlined-primary">取消</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleConfirmDelete}
|
||||
className="button-filled-error"
|
||||
>
|
||||
確認刪除
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</Can>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { Badge } from "@/Components/ui/badge";
|
||||
|
||||
export type GoodsReceiptStatus = 'processing' | 'completed' | 'cancelled';
|
||||
|
||||
export const GOODS_RECEIPT_STATUS_CONFIG: Record<string, { label: string; variant: "default" | "secondary" | "destructive" | "outline" | "success" | "warning" }> = {
|
||||
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 (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`${className} font-medium px-2.5 py-0.5 rounded-full border ${badgeClass}`}
|
||||
>
|
||||
{config.label}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
258
resources/js/Components/Inventory/GoodsReceiptTable.tsx
Normal file
258
resources/js/Components/Inventory/GoodsReceiptTable.tsx
Normal file
@@ -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<SortField | null>(null);
|
||||
const [sortDirection, setSortDirection] = useState<SortDirection>(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<string, string> = {
|
||||
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 <ArrowUpDown className="h-4 w-4 text-muted-foreground" />;
|
||||
}
|
||||
if (sortDirection === "asc") {
|
||||
return <ArrowUp className="h-4 w-4 text-primary" />;
|
||||
}
|
||||
if (sortDirection === "desc") {
|
||||
return <ArrowDown className="h-4 w-4 text-primary" />;
|
||||
}
|
||||
return <ArrowUpDown className="h-4 w-4 text-muted-foreground" />;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg border shadow-sm overflow-hidden mt-6">
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-gray-50/50">
|
||||
<TableHead className="w-[50px] text-center">#</TableHead>
|
||||
<TableHead className="w-[180px]">
|
||||
<button
|
||||
onClick={() => handleSort("code")}
|
||||
className="flex items-center gap-2 hover:text-foreground transition-colors"
|
||||
>
|
||||
進貨單編號
|
||||
<SortIcon field="code" />
|
||||
</button>
|
||||
</TableHead>
|
||||
<TableHead className="w-[120px]">
|
||||
<button
|
||||
onClick={() => handleSort("type")}
|
||||
className="flex items-center gap-2 hover:text-foreground transition-colors"
|
||||
>
|
||||
入庫類型
|
||||
<SortIcon field="type" />
|
||||
</button>
|
||||
</TableHead>
|
||||
<TableHead className="w-[180px]">
|
||||
<button
|
||||
onClick={() => handleSort("warehouse_name")}
|
||||
className="flex items-center gap-2 hover:text-foreground transition-colors"
|
||||
>
|
||||
入庫倉庫
|
||||
<SortIcon field="warehouse_name" />
|
||||
</button>
|
||||
</TableHead>
|
||||
<TableHead className="w-[180px]">
|
||||
<button
|
||||
onClick={() => handleSort("vendor_name")}
|
||||
className="flex items-center gap-2 hover:text-foreground transition-colors"
|
||||
>
|
||||
供應商
|
||||
<SortIcon field="vendor_name" />
|
||||
</button>
|
||||
</TableHead>
|
||||
<TableHead className="w-[150px]">
|
||||
<button
|
||||
onClick={() => handleSort("received_date")}
|
||||
className="flex items-center gap-2 hover:text-foreground transition-colors"
|
||||
>
|
||||
進貨日期
|
||||
<SortIcon field="received_date" />
|
||||
</button>
|
||||
</TableHead>
|
||||
<TableHead className="w-[140px] text-right">
|
||||
<button
|
||||
onClick={() => handleSort("total_amount")}
|
||||
className="flex items-center gap-2 ml-auto hover:text-foreground transition-colors"
|
||||
>
|
||||
總金額
|
||||
<SortIcon field="total_amount" />
|
||||
</button>
|
||||
</TableHead>
|
||||
<TableHead className="w-[120px] text-center">
|
||||
<button
|
||||
onClick={() => handleSort("status")}
|
||||
className="flex items-center gap-2 mx-auto hover:text-foreground transition-colors"
|
||||
>
|
||||
狀態
|
||||
<SortIcon field="status" />
|
||||
</button>
|
||||
</TableHead>
|
||||
<TableHead className="text-center">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{sortedReceipts.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={9} className="text-center text-muted-foreground py-12">
|
||||
尚無進貨單
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
sortedReceipts.map((receipt, index) => (
|
||||
<TableRow key={receipt.id}>
|
||||
<TableCell className="text-gray-500 font-medium text-center">
|
||||
{index + 1}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="font-mono text-sm font-medium">{receipt.code}</span>
|
||||
<CopyButton text={receipt.code} label="複製單號" />
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-sm">
|
||||
{/* @ts-ignore */}
|
||||
{typeMap[receipt.type] || receipt.type || "-"}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="text-sm font-medium text-gray-900">
|
||||
{receipt.warehouse?.name || "-"}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-sm text-gray-700">{receipt.vendor?.name || "-"}</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-sm text-gray-500">{formatDate(receipt.received_date)}</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<span className="font-semibold text-gray-900">
|
||||
{formatCurrency(receipt.items_sum_total_amount)}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<GoodsReceiptStatusBadge status={receipt.status} />
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<GoodsReceiptActions receipt={receipt} />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`${config.className} ${className} font-medium px-2.5 py-0.5 rounded-full`}
|
||||
variant={config.variant}
|
||||
className={`${className} font-medium px-2.5 py-0.5 rounded-full`}
|
||||
>
|
||||
{config.label}
|
||||
</Badge>
|
||||
|
||||
@@ -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}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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<PO[]>([]);
|
||||
const [selectedPO, setSelectedPO] = useState<PO | null>(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<PendingPO | null>(null);
|
||||
const [selectedVendor, setSelectedVendor] = useState<Vendor | null>(null);
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
|
||||
// Manual Selection States
|
||||
const [vendorSearch, setVendorSearch] = useState('');
|
||||
const [foundVendors, setFoundVendors] = useState<any[]>([]);
|
||||
const [selectedVendor, setSelectedVendor] = useState<any | null>(null);
|
||||
// Manual Product Search States
|
||||
const [productSearch, setProductSearch] = useState('');
|
||||
const [foundProducts, setFoundProducts] = useState<any[]>([]);
|
||||
|
||||
@@ -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<Record<string, BatchItem[]>>({});
|
||||
const [nextSequences, setNextSequences] = useState<Record<string, number>>({});
|
||||
|
||||
// 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 (
|
||||
<AuthenticatedLayout
|
||||
breadcrumbs={[
|
||||
@@ -207,9 +313,12 @@ export default function GoodsReceiptCreate({ warehouses }: { warehouses: any[] }
|
||||
<div className="container mx-auto p-6 max-w-7xl">
|
||||
{/* Header */}
|
||||
<div className="mb-6">
|
||||
<Button variant="ghost" asChild className="gap-2 button-outlined-primary mb-4 w-fit">
|
||||
<ArrowLeft className="h-4 w-4" onClick={() => window.history.back()} />
|
||||
</Button>
|
||||
<Link href={route('goods-receipts.index')}>
|
||||
<Button variant="outline" className="gap-2 mb-4 w-fit">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
返回進貨單
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
<div className="mb-4">
|
||||
<h1 className="text-2xl font-bold text-grey-0 flex items-center gap-2">
|
||||
@@ -262,11 +371,11 @@ export default function GoodsReceiptCreate({ warehouses }: { warehouses: any[] }
|
||||
{/* Step 1: Source Selection */}
|
||||
<div className="bg-white rounded-lg border shadow-sm overflow-hidden">
|
||||
<div className="p-6 bg-gray-50/50 border-b flex items-center gap-3">
|
||||
<div className={`w-8 h-8 rounded-full flex items-center justify-center font-bold ${(data.type === 'standard' ? !!selectedPO : !!selectedVendor)
|
||||
? 'bg-green-500 text-white' : 'bg-primary text-white'}`}>
|
||||
<div className={`w-8 h-8 rounded-full flex items-center justify-center font-bold text-sm ${(data.type === 'standard' ? !!selectedPO : !!selectedVendor)
|
||||
? 'bg-green-500 text-white shadow-sm' : 'bg-primary-main text-white shadow-sm'}`}>
|
||||
{(data.type === 'standard' ? selectedPO : selectedVendor) ? '✓' : '1'}
|
||||
</div>
|
||||
<h2 className="text-lg font-bold">
|
||||
<h2 className="text-lg font-bold text-gray-800">
|
||||
{data.type === 'standard' ? '選擇來源採購單' : '選擇供應商'}
|
||||
</h2>
|
||||
</div>
|
||||
@@ -275,41 +384,40 @@ export default function GoodsReceiptCreate({ warehouses }: { warehouses: any[] }
|
||||
{data.type === 'standard' ? (
|
||||
!selectedPO ? (
|
||||
<div className="space-y-4">
|
||||
<div className="flex gap-4 items-end">
|
||||
<div className="flex-1 space-y-1">
|
||||
<Label className="text-xs font-medium text-gray-500">採購單搜尋</Label>
|
||||
<Input
|
||||
placeholder="輸入採購單號或供應商名稱搜尋..."
|
||||
value={poSearch}
|
||||
onChange={(e) => setPoSearch(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && searchPO()}
|
||||
className="h-9"
|
||||
/>
|
||||
</div>
|
||||
<Button onClick={searchPO} disabled={isSearching} className="button-filled-primary h-9">
|
||||
<Search className="mr-2 h-4 w-4" />
|
||||
{isSearching ? '搜尋中...' : '搜尋'}
|
||||
</Button>
|
||||
</div>
|
||||
<Label className="text-sm font-medium text-gray-700">請選擇待進貨的採購單</Label>
|
||||
|
||||
{foundPOs.length > 0 && (
|
||||
{pendingPurchaseOrders.length === 0 ? (
|
||||
<div className="text-center py-8 text-gray-500 bg-gray-50 rounded-lg border border-dashed">
|
||||
目前沒有待進貨的採購單
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader className="bg-gray-50">
|
||||
<TableRow>
|
||||
<TableHead>單號</TableHead>
|
||||
<TableHead>採購單號</TableHead>
|
||||
<TableHead>供應商</TableHead>
|
||||
<TableHead className="text-center">狀態</TableHead>
|
||||
<TableHead className="text-center">待收項目</TableHead>
|
||||
<TableHead className="w-[100px] text-center">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{foundPOs.map((po) => (
|
||||
<TableRow key={po.id}>
|
||||
{pendingPurchaseOrders.map((po) => (
|
||||
<TableRow key={po.id} className="hover:bg-gray-50/50">
|
||||
<TableCell className="font-medium text-primary-main">{po.code}</TableCell>
|
||||
<TableCell>{po.vendor?.name}</TableCell>
|
||||
<TableCell>{po.vendor_name}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<Button size="sm" onClick={() => handleSelectPO(po)} className="button-outlined-primary">
|
||||
帶入
|
||||
<Badge variant={STATUS_CONFIG[po.status]?.variant || 'outline'}>
|
||||
{STATUS_CONFIG[po.status]?.label || po.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-center text-gray-600">
|
||||
{po.items.length} 項
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<Button size="sm" onClick={() => handleSelectPO(po)} className="button-filled-primary">
|
||||
選擇
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
@@ -328,7 +436,11 @@ export default function GoodsReceiptCreate({ warehouses }: { warehouses: any[] }
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-xs text-gray-500 block">供應商</span>
|
||||
<span className="font-bold text-gray-800">{selectedPO.vendor?.name}</span>
|
||||
<span className="font-bold text-gray-800">{selectedPO.vendor_name}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-xs text-gray-500 block">待收項目</span>
|
||||
<span className="font-bold text-gray-800">{selectedPO.items.length} 項</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" onClick={() => setSelectedPO(null)} className="text-gray-500 hover:text-red-500">
|
||||
@@ -339,47 +451,23 @@ export default function GoodsReceiptCreate({ warehouses }: { warehouses: any[] }
|
||||
) : (
|
||||
!selectedVendor ? (
|
||||
<div className="space-y-4">
|
||||
<div className="flex gap-4 items-end">
|
||||
<div className="flex-1 space-y-1">
|
||||
<Label className="text-xs font-medium text-gray-500">供應商搜尋</Label>
|
||||
<Input
|
||||
placeholder="輸入供應商名稱或代號搜尋..."
|
||||
value={vendorSearch}
|
||||
onChange={(e) => setVendorSearch(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && searchVendors()}
|
||||
className="h-9"
|
||||
/>
|
||||
</div>
|
||||
<Button onClick={searchVendors} disabled={isSearching} className="button-filled-primary h-9">
|
||||
<Search className="mr-2 h-4 w-4" />
|
||||
{isSearching ? '搜尋中...' : '搜尋'}
|
||||
</Button>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium text-gray-700">請選擇供應商</Label>
|
||||
<SearchableSelect
|
||||
value=""
|
||||
onValueChange={handleSelectVendor}
|
||||
options={vendors.map(v => ({
|
||||
label: `${v.name} (${v.code})`,
|
||||
value: v.id.toString()
|
||||
}))}
|
||||
placeholder="選擇供應商..."
|
||||
searchPlaceholder="搜尋供應商..."
|
||||
className="h-9 w-full max-w-md"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{foundVendors.length > 0 && (
|
||||
<div className="bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader className="bg-gray-50">
|
||||
<TableRow>
|
||||
<TableHead>名稱</TableHead>
|
||||
<TableHead>代號</TableHead>
|
||||
<TableHead className="w-[100px] text-center">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{foundVendors.map((v) => (
|
||||
<TableRow key={v.id}>
|
||||
<TableCell className="font-medium">{v.name}</TableCell>
|
||||
<TableCell>{v.code}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<Button size="sm" onClick={() => handleSelectVendor(v)} className="button-outlined-primary">
|
||||
選擇
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
{vendors.length === 0 && (
|
||||
<div className="text-center py-8 text-gray-500 bg-gray-50 rounded-lg border border-dashed">
|
||||
目前沒有可選擇的供應商
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -408,8 +496,8 @@ export default function GoodsReceiptCreate({ warehouses }: { warehouses: any[] }
|
||||
{((data.type === 'standard' && selectedPO) || (data.type !== 'standard' && selectedVendor)) && (
|
||||
<div className="bg-white rounded-lg border shadow-sm overflow-hidden animate-in fade-in slide-in-from-bottom-4 duration-500">
|
||||
<div className="p-6 bg-gray-50/50 border-b flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-full bg-primary text-white flex items-center justify-center font-bold">2</div>
|
||||
<h2 className="text-lg font-bold">進貨資訊與明細</h2>
|
||||
<div className="w-8 h-8 rounded-full bg-primary-main text-white flex items-center justify-center font-bold text-sm shadow-sm">2</div>
|
||||
<h2 className="text-lg font-bold text-gray-800">進貨資訊與明細</h2>
|
||||
</div>
|
||||
|
||||
<div className="p-6 space-y-8">
|
||||
@@ -491,125 +579,163 @@ export default function GoodsReceiptCreate({ warehouses }: { warehouses: any[] }
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader className="bg-gray-50">
|
||||
<TableRow>
|
||||
<TableHead className="w-[200px]">商品資訊</TableHead>
|
||||
<TableHead className="w-[120px] text-center">
|
||||
{data.type === 'standard' ? '採購量 / 已收' : '規格'}
|
||||
</TableHead>
|
||||
<TableHead className="w-[100px] text-right">單價</TableHead>
|
||||
<TableHead className="w-[100px]">收貨量 <span className="text-red-500">*</span></TableHead>
|
||||
<TableHead className="w-[120px]">批號</TableHead>
|
||||
<TableHead className="w-[120px]">效期</TableHead>
|
||||
<TableHead className="w-[80px] text-right">小計</TableHead>
|
||||
{data.type !== 'standard' && <TableHead className="w-[50px]"></TableHead>}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{data.items.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={data.type === 'standard' ? 7 : 8} className="text-center py-8 text-gray-400 italic">
|
||||
尚無明細,請搜尋商品加入。
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
data.items.map((item, index) => {
|
||||
const errorKey = `items.${index}.quantity_received` as keyof typeof errors;
|
||||
return (
|
||||
<TableRow key={index} className="hover:bg-gray-50/50 text-sm">
|
||||
<TableCell>
|
||||
<div className="font-medium text-gray-900">{item.product_name}</div>
|
||||
<div className="text-xs text-gray-500">{item.sku}</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-center text-gray-600">
|
||||
{data.type === 'standard'
|
||||
? `${item.quantity_ordered} / ${item.quantity_received_so_far}`
|
||||
: '一般'}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={item.unit_price}
|
||||
onChange={(e) => updateItem(index, 'unit_price', e.target.value)}
|
||||
className="h-8 text-right w-20 ml-auto"
|
||||
disabled={data.type === 'standard'}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
value={item.quantity_received}
|
||||
onChange={(e) => updateItem(index, 'quantity_received', e.target.value)}
|
||||
className={`h-8 w-20 ${errors[errorKey] ? 'border-red-500' : ''}`}
|
||||
/>
|
||||
{errors[errorKey] && (
|
||||
<p className="text-red-500 text-[10px] mt-1">{errors[errorKey] as string}</p>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Input
|
||||
value={item.batch_number}
|
||||
onChange={(e) => updateItem(index, 'batch_number', e.target.value)}
|
||||
placeholder="選填"
|
||||
className="h-8"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Input
|
||||
type="date"
|
||||
value={item.expiry_date}
|
||||
onChange={(e) => updateItem(index, 'expiry_date', e.target.value)}
|
||||
className="h-8"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-medium">
|
||||
${(parseFloat(item.quantity_received || 0) * parseFloat(item.unit_price)).toLocaleString()}
|
||||
</TableCell>
|
||||
{data.type !== 'standard' && (
|
||||
<TableCell className="text-center">
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
{/* 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 (
|
||||
<>
|
||||
<div className="bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader className="bg-gray-50/50">
|
||||
<TableRow>
|
||||
<TableHead className="w-[180px]">商品資訊</TableHead>
|
||||
<TableHead className="w-[80px] text-center">總數量</TableHead>
|
||||
<TableHead className="w-[80px] text-center">待收貨</TableHead>
|
||||
<TableHead className="w-[120px]">本次收貨 <span className="text-red-500">*</span></TableHead>
|
||||
<TableHead className="w-[200px]">批號設定 <span className="text-red-500">*</span></TableHead>
|
||||
<TableHead className="w-[150px]">效期</TableHead>
|
||||
<TableHead className="w-[80px] text-right">小計</TableHead>
|
||||
<TableHead className="w-[50px]"></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{data.items.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={8} className="text-center py-8 text-gray-400 italic">
|
||||
尚無明細,請搜尋商品加入。
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
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 (
|
||||
<TableRow key={index} className="hover:bg-gray-50/50 text-sm">
|
||||
{/* Product Info */}
|
||||
<TableCell>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium text-gray-900">{item.product_name}</span>
|
||||
<span className="text-xs text-gray-500">{item.sku}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
|
||||
{/* Total Quantity */}
|
||||
<TableCell className="text-center">
|
||||
<span className="text-gray-500 text-sm">
|
||||
{Math.round(item.quantity_ordered)}
|
||||
</span>
|
||||
</TableCell>
|
||||
|
||||
{/* Remaining */}
|
||||
<TableCell className="text-center">
|
||||
<span className="text-gray-900 font-medium text-sm">
|
||||
{Math.round(item.quantity_ordered - item.quantity_received_so_far)}
|
||||
</span>
|
||||
</TableCell>
|
||||
|
||||
{/* Received Quantity */}
|
||||
<TableCell>
|
||||
<Input
|
||||
type="number"
|
||||
step="1"
|
||||
min="0"
|
||||
value={item.quantity_received}
|
||||
onChange={(e) => updateItem(index, 'quantity_received', e.target.value)}
|
||||
className={`w-full ${(errors as any)[errorKey] ? 'border-red-500' : ''}`}
|
||||
/>
|
||||
{(errors as any)[errorKey] && (
|
||||
<p className="text-xs text-red-500 mt-1">{(errors as any)[errorKey]}</p>
|
||||
)}
|
||||
</TableCell>
|
||||
|
||||
{/* Batch Settings */}
|
||||
<TableCell>
|
||||
<div className="flex gap-2 items-center">
|
||||
<Input
|
||||
value={item.originCountry || 'TW'}
|
||||
onChange={(e) => updateItem(index, 'originCountry', e.target.value.toUpperCase().slice(0, 2))}
|
||||
placeholder="產地"
|
||||
maxLength={2}
|
||||
className="w-16 text-center px-1"
|
||||
/>
|
||||
<div className="flex-1 text-sm font-mono bg-gray-50 px-3 py-2 rounded text-gray-600 truncate">
|
||||
{getBatchPreview(item.product_id, item.sku, item.originCountry || 'TW', data.received_date)}
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
|
||||
{/* Expiry Date */}
|
||||
<TableCell>
|
||||
<div className="relative">
|
||||
<CalendarIcon className="absolute left-2.5 top-2.5 h-4 w-4 text-gray-400 pointer-events-none" />
|
||||
<Input
|
||||
type="date"
|
||||
value={item.expiry_date}
|
||||
onChange={(e) => updateItem(index, 'expiry_date', e.target.value)}
|
||||
className={`pl-9 ${item.batchMode === 'existing' ? 'bg-gray-50' : ''}`}
|
||||
disabled={item.batchMode === 'existing'}
|
||||
/>
|
||||
</div>
|
||||
</TableCell>
|
||||
|
||||
{/* Subtotal */}
|
||||
<TableCell className="text-right font-medium">
|
||||
${itemTotal.toLocaleString()}
|
||||
</TableCell>
|
||||
|
||||
{/* Actions */}
|
||||
<TableCell>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="button-outlined-error"
|
||||
title="移除項目"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => removeItem(index)}
|
||||
className="hover:bg-red-50 hover:text-red-600 h-8 w-8"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>確定要移除此商品嗎?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
此動作將從清單中移除該商品,您之後需要重新搜尋才能再次加入。
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>取消</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => removeItem(index)}
|
||||
className="bg-red-600 hover:bg-red-700"
|
||||
>
|
||||
確定移除
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</TableCell>
|
||||
)}
|
||||
</TableRow>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex justify-end">
|
||||
<div className="w-full max-w-sm bg-primary/5 px-6 py-4 rounded-xl border border-primary/10 flex flex-col gap-3">
|
||||
<div className="flex justify-between items-center w-full">
|
||||
<span className="text-sm text-gray-500 font-medium">小計</span>
|
||||
<span className="text-lg font-bold text-gray-700">${subTotal.toLocaleString()}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-center w-full">
|
||||
<span className="text-sm text-gray-500 font-medium">稅額 (5%)</span>
|
||||
<span className="text-lg font-bold text-gray-700">${taxAmount.toLocaleString()}</span>
|
||||
</div>
|
||||
|
||||
<div className="h-px bg-primary/10 w-full my-1"></div>
|
||||
|
||||
<div className="flex justify-between items-end w-full">
|
||||
<span className="text-sm text-gray-500 font-medium mb-1">總計金額</span>
|
||||
<span className="text-2xl font-black text-primary">
|
||||
${grandTotal.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -632,6 +758,6 @@ export default function GoodsReceiptCreate({ warehouses }: { warehouses: any[] }
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</AuthenticatedLayout>
|
||||
</AuthenticatedLayout >
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<AuthenticatedLayout
|
||||
breadcrumbs={[
|
||||
@@ -56,79 +149,177 @@ export default function GoodsReceiptIndex({ receipts, filters }: any) {
|
||||
</div>
|
||||
|
||||
{/* Filter Bar */}
|
||||
<div className="bg-white p-4 rounded-xl border border-gray-200 mb-6 shadow-sm">
|
||||
<form onSubmit={handleSearch} className="flex gap-4 items-end">
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium text-gray-500">關鍵字搜尋</label>
|
||||
<div className="flex gap-2">
|
||||
<div className="bg-white p-5 rounded-lg shadow-sm border border-gray-200 mb-6">
|
||||
{/* Row 1: Search, Status, Warehouse */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-12 gap-4 mb-4">
|
||||
<div className="md:col-span-4 space-y-1">
|
||||
<Label className="text-xs font-medium text-grey-1">關鍵字搜尋</Label>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 h-4 w-4" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="搜尋單號..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="w-64 h-9"
|
||||
className="pl-10 h-9 block"
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleFilter()}
|
||||
/>
|
||||
<Button type="submit" variant="outline" size="sm" className="h-9 w-9 p-0 button-outlined-primary">
|
||||
<Search className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div className="md:col-span-4 space-y-1">
|
||||
<Label className="text-xs font-medium text-grey-1">狀態</Label>
|
||||
<Select value={status} onValueChange={setStatus}>
|
||||
<SelectTrigger className="h-9">
|
||||
<SelectValue placeholder="選擇狀態" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{statusOptions.map(opt => (
|
||||
<SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="md:col-span-4 space-y-1">
|
||||
<Label className="text-xs font-medium text-grey-1">倉庫</Label>
|
||||
<SearchableSelect
|
||||
value={warehouseId}
|
||||
onValueChange={setWarehouseId}
|
||||
options={warehouseOptions}
|
||||
placeholder="選擇倉庫"
|
||||
className="w-full h-9"
|
||||
showSearch={warehouses.length > 10}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Row 2: Date Filters (Collapsible) */}
|
||||
{showAdvanced && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-12 gap-4 items-end animate-in fade-in slide-in-from-top-2 duration-200">
|
||||
<div className="md:col-span-6 space-y-2">
|
||||
<Label className="text-xs font-medium text-grey-1">快速時間區間</Label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{[
|
||||
{ label: "今日", value: "today" },
|
||||
{ label: "昨日", value: "yesterday" },
|
||||
{ label: "本週", value: "this_week" },
|
||||
{ label: "本月", value: "this_month" },
|
||||
{ label: "上月", value: "last_month" },
|
||||
].map((opt) => (
|
||||
<Button
|
||||
key={opt.value}
|
||||
size="sm"
|
||||
onClick={() => handleDateRangeChange(opt.value)}
|
||||
className={
|
||||
dateRangeType === opt.value
|
||||
? 'button-filled-primary h-9 px-4 shadow-sm'
|
||||
: 'button-outlined-primary h-9 px-4 bg-white'
|
||||
}
|
||||
>
|
||||
{opt.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="md:col-span-6">
|
||||
<div className="grid grid-cols-2 gap-4 items-end">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-grey-2 font-medium">開始日期</Label>
|
||||
<div className="relative">
|
||||
<Calendar className="absolute left-2.5 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400 pointer-events-none" />
|
||||
<Input
|
||||
type="date"
|
||||
value={dateStart}
|
||||
onChange={(e) => {
|
||||
setDateStart(e.target.value);
|
||||
setDateRangeType('custom');
|
||||
}}
|
||||
className="pl-9 block w-full h-9 bg-white"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-grey-2 font-medium">結束日期</Label>
|
||||
<div className="relative">
|
||||
<Calendar className="absolute left-2.5 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400 pointer-events-none" />
|
||||
<Input
|
||||
type="date"
|
||||
value={dateEnd}
|
||||
onChange={(e) => {
|
||||
setDateEnd(e.target.value);
|
||||
setDateRangeType('custom');
|
||||
}}
|
||||
className="pl-9 block w-full h-9 bg-white text-left"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-end border-t border-gray-100 pt-5 gap-3 mt-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setShowAdvanced(!showAdvanced)}
|
||||
className="mr-auto text-gray-500 hover:text-gray-900 h-9"
|
||||
>
|
||||
{showAdvanced ? (
|
||||
<>
|
||||
<ChevronUp className="h-4 w-4 mr-1" />
|
||||
收合篩選
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ChevronDown className="h-4 w-4 mr-1" />
|
||||
進階篩選
|
||||
{(dateStart || dateEnd) && (
|
||||
<span className="ml-2 w-2 h-2 rounded-full bg-primary-main" />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleReset}
|
||||
className="flex items-center gap-2 button-outlined-primary h-9"
|
||||
>
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
重置
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleFilter}
|
||||
className="flex items-center gap-2 button-filled-primary h-9 px-6"
|
||||
>
|
||||
<Search className="h-4 w-4" />
|
||||
查詢
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Table Section */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader className="bg-gray-50">
|
||||
<TableRow>
|
||||
<TableHead className="w-[180px]">單號</TableHead>
|
||||
<TableHead>倉庫</TableHead>
|
||||
<TableHead>供應商ID</TableHead>
|
||||
<TableHead className="w-[120px] text-center">進貨日期</TableHead>
|
||||
<TableHead className="w-[100px] text-center">狀態</TableHead>
|
||||
<TableHead className="w-[100px] text-center">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{receipts.data.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="h-24 text-center text-gray-500">
|
||||
尚無進貨紀錄
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
receipts.data.map((receipt: any) => (
|
||||
<TableRow key={receipt.id}>
|
||||
<TableCell className="font-medium text-gray-900">{receipt.code}</TableCell>
|
||||
<TableCell className="text-gray-600">{receipt.warehouse?.name}</TableCell>
|
||||
<TableCell className="text-gray-600">{receipt.vendor_id}</TableCell>
|
||||
<TableCell className="text-center text-gray-600">{receipt.received_date}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<Badge variant="outline" className={
|
||||
receipt.status === 'completed'
|
||||
? 'bg-green-50 text-green-700 border-green-200'
|
||||
: 'bg-gray-50 text-gray-700 border-gray-200'
|
||||
}>
|
||||
{receipt.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<Can permission="goods_receipts.view">
|
||||
<Button variant="outline" size="sm" className="button-outlined-primary" title="查看詳情">
|
||||
<FileText className="h-4 w-4" />
|
||||
</Button>
|
||||
</Can>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<GoodsReceiptTable receipts={receipts.data} />
|
||||
|
||||
<div className="mt-6">
|
||||
{/* Pagination */}
|
||||
<div className="mt-4 flex flex-col sm:flex-row items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-2 text-sm text-gray-500">
|
||||
<span>每頁顯示</span>
|
||||
<SearchableSelect
|
||||
value={perPage}
|
||||
onValueChange={handlePerPageChange}
|
||||
options={[
|
||||
{ label: "10", value: "10" },
|
||||
{ label: "20", value: "20" },
|
||||
{ label: "50", value: "50" },
|
||||
{ label: "100", value: "100" }
|
||||
]}
|
||||
className="w-[100px] h-8"
|
||||
showSearch={false}
|
||||
/>
|
||||
<span>筆</span>
|
||||
</div>
|
||||
<Pagination links={receipts.links} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
221
resources/js/Pages/Inventory/GoodsReceipt/Show.tsx
Normal file
221
resources/js/Pages/Inventory/GoodsReceipt/Show.tsx
Normal file
@@ -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<string, string> = {
|
||||
standard: "標準採購進貨",
|
||||
miscellaneous: "雜項入庫",
|
||||
other: "其他入庫",
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthenticatedLayout breadcrumbs={getShowBreadcrumbs("goodsReceipts", `詳情 (#${receipt.code})`)}>
|
||||
<Head title={`進貨單詳情 - ${receipt.code}`} />
|
||||
<div className="container mx-auto p-6 max-w-7xl">
|
||||
{/* Header */}
|
||||
<div className="mb-6">
|
||||
<Link href="/goods-receipts">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="gap-2 button-outlined-primary mb-6"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
返回進貨單列表
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-grey-0 flex items-center gap-2">
|
||||
<Package className="h-6 w-6 text-primary-main" />
|
||||
查看進貨單
|
||||
</h1>
|
||||
<p className="text-gray-500 mt-1">單號:{receipt.code}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<GoodsReceiptStatusBadge status={receipt.status} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-8">
|
||||
{/* 基本資訊卡片 */}
|
||||
<div className="bg-white rounded-lg border shadow-sm p-6">
|
||||
<h2 className="text-lg font-bold text-gray-900 mb-6 border-b pb-4">基本資訊</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-x-8 gap-y-6">
|
||||
<div>
|
||||
<span className="text-sm text-gray-500 block mb-1">進貨單編號</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="font-mono font-medium text-gray-900">{receipt.code}</span>
|
||||
<CopyButton text={receipt.code} label="複製單號" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-sm text-gray-500 block mb-1">入庫類型</span>
|
||||
<span className="font-medium text-gray-900">{typeMap[receipt.type] || receipt.type}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-sm text-gray-500 block mb-1">倉庫</span>
|
||||
<span className="font-medium text-gray-900">{receipt.warehouse?.name || "-"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-sm text-gray-500 block mb-1">供應商</span>
|
||||
<span className="font-medium text-gray-900">{receipt.vendor?.name || "-"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-sm text-gray-500 block mb-1">進貨日期</span>
|
||||
<span className="font-medium text-gray-900">{formatDate(receipt.received_date)}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-sm text-gray-500 block mb-1">建立時間</span>
|
||||
<span className="font-medium text-gray-900">{formatDateTime(receipt.created_at)}</span>
|
||||
</div>
|
||||
</div>
|
||||
{receipt.remark && (
|
||||
<div className="mt-8 pt-6 border-t border-gray-100">
|
||||
<span className="text-sm text-gray-500 block mb-2">備註</span>
|
||||
<p className="text-sm text-gray-700 bg-gray-50 p-4 rounded-lg leading-relaxed">
|
||||
{receipt.remark}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 品項清單卡片 */}
|
||||
<div className="bg-white rounded-lg border shadow-sm overflow-hidden">
|
||||
<div className="p-6 border-b border-gray-100">
|
||||
<h2 className="text-lg font-bold text-gray-900">進貨品項清單</h2>
|
||||
</div>
|
||||
<div className="p-0">
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-gray-50/50">
|
||||
<TableHead className="w-[80px] text-center">#</TableHead>
|
||||
<TableHead>商品名稱</TableHead>
|
||||
<TableHead className="text-right">進貨數量</TableHead>
|
||||
<TableHead className="text-center">單位</TableHead>
|
||||
<TableHead className="text-right">單價</TableHead>
|
||||
<TableHead className="text-right">小計</TableHead>
|
||||
<TableHead>批號</TableHead>
|
||||
<TableHead>效期</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{receipt.items.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={8} className="h-24 text-center text-gray-500">
|
||||
無品項資料
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
receipt.items.map((item, index) => (
|
||||
<TableRow key={item.id}>
|
||||
<TableCell className="text-center text-gray-500">{index + 1}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium text-gray-900">{item.product.name}</span>
|
||||
<span className="text-xs text-gray-500 font-mono">{item.product.code}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-medium">
|
||||
{Number(item.quantity_received).toLocaleString()}
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
{item.product.baseUnit?.name || "個"}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{formatCurrency(Number(item.unit_price))}
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-bold text-primary">
|
||||
{formatCurrency(Number(item.total_amount))}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-sm font-mono">{item.batch_number || "-"}</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-sm">{item.expiry_date ? formatDate(item.expiry_date) : "-"}</span>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* 總計 */}
|
||||
<div className="p-6 border-t border-gray-100 flex justify-end">
|
||||
<div className="w-full max-w-xs bg-gray-50/50 px-6 py-4 rounded-xl border border-gray-100 flex flex-col gap-3">
|
||||
<div className="flex justify-between items-end w-full">
|
||||
<span className="text-sm text-gray-500 font-medium mb-1">總計金額</span>
|
||||
<span className="text-2xl font-black text-primary">
|
||||
{formatCurrency(receipt.items_sum_total_amount)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AuthenticatedLayout>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">全部狀態</SelectItem>
|
||||
<SelectItem value="draft">草稿</SelectItem>
|
||||
<SelectItem value="pending">待審核</SelectItem>
|
||||
<SelectItem value="processing">處理中</SelectItem>
|
||||
<SelectItem value="shipping">運送中</SelectItem>
|
||||
<SelectItem value="confirming">待確認</SelectItem>
|
||||
<SelectItem value="completed">已完成</SelectItem>
|
||||
<SelectItem value="partial">部分進貨</SelectItem>
|
||||
<SelectItem value="cancelled">已取消</SelectItem>
|
||||
{STATUS_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
@@ -147,20 +147,26 @@ export default function ViewPurchaseOrderPage({ order }: Props) {
|
||||
items={order.items}
|
||||
isReadOnly={true}
|
||||
/>
|
||||
<div className="mt-4 flex flex-col items-end gap-2 border-t pt-4">
|
||||
<div className="flex items-center gap-8 text-gray-600">
|
||||
<span className="font-medium">小計</span>
|
||||
<span>{formatCurrency(order.totalAmount)}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-8 text-gray-600">
|
||||
<span className="font-medium">稅額</span>
|
||||
<span>{formatCurrency(order.tax_amount || 0)}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-8 pt-2 mt-2 border-t border-gray-100">
|
||||
<span className="font-bold text-lg">總計</span>
|
||||
<span className="text-xl font-bold text-primary">
|
||||
{formatCurrency(order.grand_total || (order.totalAmount + (order.tax_amount || 0)))}
|
||||
</span>
|
||||
<div className="mt-6 flex justify-end">
|
||||
<div className="w-full max-w-sm bg-primary/5 px-6 py-4 rounded-xl border border-primary/10 flex flex-col gap-3">
|
||||
<div className="flex justify-between items-center w-full">
|
||||
<span className="text-sm text-gray-500 font-medium">小計</span>
|
||||
<span className="text-lg font-bold text-gray-700">{formatCurrency(order.totalAmount)}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-center w-full">
|
||||
<span className="text-sm text-gray-500 font-medium">稅額</span>
|
||||
<span className="text-lg font-bold text-gray-700">{formatCurrency(order.taxAmount || 0)}</span>
|
||||
</div>
|
||||
|
||||
<div className="h-px bg-primary/10 w-full my-1"></div>
|
||||
|
||||
<div className="flex justify-between items-end w-full">
|
||||
<span className="text-sm text-gray-500 font-medium mb-1">總計 (含稅)</span>
|
||||
<span className="text-2xl font-black text-primary">
|
||||
{formatCurrency(order.grandTotal || (order.totalAmount + (order.taxAmount || 0)))}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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]) => ({
|
||||
|
||||
27
resources/js/types/goods-receipt.ts
Normal file
27
resources/js/types/goods-receipt.ts
Normal file
@@ -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;
|
||||
};
|
||||
}
|
||||
@@ -4,14 +4,12 @@
|
||||
|
||||
export type PurchaseOrderStatus =
|
||||
| "draft" // 草稿
|
||||
| "pending" // 待審核
|
||||
| "processing" // 處理中
|
||||
| "shipping" // 運送中
|
||||
| "confirming" // 待確認
|
||||
| "completed" // 已完成
|
||||
| "completed" // 已完成
|
||||
| "cancelled" // 已取消
|
||||
| "partial"; // 部分進貨
|
||||
| "pending" // 簽核中
|
||||
| "approved" // 已核准
|
||||
| "partial" // 部分收貨
|
||||
| "completed" // 全數收貨
|
||||
| "closed" // 已結案
|
||||
| "cancelled"; // 已作廢
|
||||
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user