Files
star-erp/resources/js/Components/Warehouse/TransferOrderDialog.tsx
sky121113 71458dd976
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Has been skipped
Koori-ERP-Deploy-System / deploy-production (push) Successful in 53s
feat(inventory): 實作撥補單建立即自動過帳邏輯並修正參數對齊問題
2026-02-02 09:27:02 +08:00

350 lines
11 KiB
TypeScript

/**
* 撥補單對話框元件
* 重構後:加入驗證邏輯模組化
*/
import { useState, useEffect } from "react";
import { getCurrentDateTime } from "@/utils/format";
import axios from "axios";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/Components/ui/dialog";
import { Label } from "@/Components/ui/label";
import { Input } from "@/Components/ui/input";
import { Button } from "@/Components/ui/button";
import { SearchableSelect } from "@/Components/ui/searchable-select";
import { Textarea } from "@/Components/ui/textarea";
import { toast } from "sonner";
import { Warehouse, TransferOrder, TransferOrderStatus } from "@/types/warehouse";
import { validateTransferOrder, validateTransferQuantity } from "@/utils/validation";
import { usePermission } from "@/hooks/usePermission";
export type { TransferOrder };
interface TransferOrderDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
order: TransferOrder | null;
warehouses: Warehouse[];
// inventories: WarehouseInventory[]; // 因從 API 獲取而移除
onSave: (order: Omit<TransferOrder, "id" | "createdAt" | "orderNumber">) => void;
}
interface AvailableProduct {
productId: string;
productName: string;
batchNumber: string;
availableQty: number;
unit: string;
expiryDate: string | null;
unitCost: number; // 新增
totalValue: number; // 新增
}
export default function TransferOrderDialog({
open,
onOpenChange,
order,
warehouses,
// inventories,
onSave,
}: TransferOrderDialogProps) {
const { can } = usePermission();
const canViewCost = can('inventory.view_cost');
const [formData, setFormData] = useState({
sourceWarehouseId: "",
targetWarehouseId: "",
productId: "",
productName: "",
batchNumber: "",
quantity: 0,
transferDate: getCurrentDateTime(),
status: "待處理" as TransferOrderStatus,
notes: "",
});
const [availableProducts, setAvailableProducts] = useState<AvailableProduct[]>([]);
// 當對話框開啟或訂單變更時,重置表單
useEffect(() => {
if (order) {
setFormData({
sourceWarehouseId: order.sourceWarehouseId,
targetWarehouseId: order.targetWarehouseId,
productId: order.productId,
productName: order.productName,
batchNumber: order.batchNumber,
quantity: order.quantity,
transferDate: order.transferDate,
status: order.status,
notes: order.notes || "",
});
} else {
setFormData({
sourceWarehouseId: "",
targetWarehouseId: "",
productId: "",
productName: "",
batchNumber: "",
quantity: 0,
transferDate: getCurrentDateTime(),
status: "待處理",
notes: "",
});
}
}, [order, open]);
// 當來源倉庫變更時,從 API 更新可用商品列表
useEffect(() => {
if (formData.sourceWarehouseId) {
axios.get(route('api.warehouses.inventories', formData.sourceWarehouseId))
.then(response => {
const mappedData = response.data.map((item: any) => ({
productId: item.product_id,
productName: item.product_name,
batchNumber: item.batch_number,
availableQty: item.quantity,
unit: item.unit_name,
expiryDate: item.expiry_date,
unitCost: item.unit_cost, // 映射
totalValue: item.total_value, // 映射
}));
setAvailableProducts(mappedData);
})
.catch(error => {
console.error("Failed to fetch inventories:", error);
toast.error("無法取得倉庫庫存資訊");
setAvailableProducts([]);
});
} else {
setAvailableProducts([]);
}
}, [formData.sourceWarehouseId]);
const handleSubmit = () => {
// 基本驗證
const validation = validateTransferOrder(formData);
if (!validation.isValid) {
toast.error(validation.error);
return;
}
// 檢查可用數量
const selectedProduct = availableProducts.find(
(p) => p.productId === formData.productId && p.batchNumber === formData.batchNumber
);
if (selectedProduct) {
const quantityValidation = validateTransferQuantity(
formData.quantity,
selectedProduct.availableQty
);
if (!quantityValidation.isValid) {
toast.error(quantityValidation.error);
return;
}
}
onSave({
from_warehouse_id: formData.sourceWarehouseId,
to_warehouse_id: formData.targetWarehouseId,
product_id: formData.productId,
quantity: formData.quantity,
batch_number: formData.batchNumber,
notes: formData.notes,
instant_post: true,
} as any);
};
const handleProductChange = (productKey: string) => {
const [productId, batchNumber] = productKey.split("|||");
const product = availableProducts.find(
(p) => p.productId === productId && p.batchNumber === batchNumber
);
if (product) {
setFormData({
...formData,
productId: product.productId,
productName: product.productName,
batchNumber: product.batchNumber,
quantity: 0,
});
}
};
const selectedProduct = availableProducts.find(
(p) => p.productId === formData.productId && p.batchNumber === formData.batchNumber
);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{order ? "編輯撥補單" : "新增撥補單"}</DialogTitle>
<DialogDescription></DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
{/* 來源倉庫和目標倉庫 */}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="sourceWarehouse">
<span className="text-red-500">*</span>
</Label>
<SearchableSelect
value={formData.sourceWarehouseId}
onValueChange={(value) =>
setFormData({
...formData,
sourceWarehouseId: value,
productId: "",
productName: "",
batchNumber: "",
quantity: 0,
})
}
disabled={!!order}
options={warehouses.map((warehouse) => ({ label: warehouse.name, value: warehouse.id }))}
placeholder="選擇來源倉庫"
searchPlaceholder="搜尋倉庫..."
/>
</div>
<div className="space-y-2">
<Label htmlFor="targetWarehouse">
<span className="text-red-500">*</span>
</Label>
<SearchableSelect
value={formData.targetWarehouseId}
onValueChange={(value) =>
setFormData({ ...formData, targetWarehouseId: value })
}
disabled={!!order}
options={warehouses
.filter((w) => w.id !== formData.sourceWarehouseId)
.map((warehouse) => ({ label: warehouse.name, value: warehouse.id }))}
placeholder="選擇目標倉庫"
searchPlaceholder="搜尋倉庫..."
/>
</div>
</div>
{/* 商品選擇 */}
<div className="space-y-2">
<Label htmlFor="product">
<span className="text-red-500">*</span>
</Label>
<SearchableSelect
value={
formData.productId && formData.batchNumber
? `${formData.productId}|||${formData.batchNumber}`
: ""
}
onValueChange={handleProductChange}
disabled={!formData.sourceWarehouseId || !!order}
options={availableProducts.map((product) => ({
label: `${product.productName} | 批號: ${product.batchNumber || '-'} | 效期: ${product.expiryDate || '-'} (庫存: ${product.availableQty} ${product.unit})${canViewCost ? ` | 成本: $${product.unitCost?.toLocaleString()}` : ''}`,
value: `${product.productId}|||${product.batchNumber}`,
}))}
placeholder="選擇商品與批號"
searchPlaceholder="搜尋商品..."
emptyText={formData.sourceWarehouseId ? "該倉庫無可用庫存" : "請先選擇來源倉庫"}
/>
</div>
{/* 數量和日期 */}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="quantity">
<span className="text-red-500">*</span>
</Label>
<Input
id="quantity"
type="number"
min="0"
max={selectedProduct?.availableQty || 0}
value={formData.quantity}
onChange={(e) =>
setFormData({ ...formData, quantity: Number(e.target.value) })
}
/>
<div className="h-5">
{selectedProduct && (
<p className="text-sm text-gray-500">
: {selectedProduct.availableQty} {selectedProduct.unit}
</p>
)}
</div>
</div>
<div className="space-y-2">
<Label htmlFor="transferDate">
<span className="text-red-500">*</span>
</Label>
<Input
id="transferDate"
type="datetime-local"
value={formData.transferDate}
onChange={(e) =>
setFormData({ ...formData, transferDate: e.target.value })
}
/>
</div>
</div>
{/* 狀態(僅編輯時顯示) */}
{order && (
<div className="space-y-2">
<Label htmlFor="status"></Label>
<SearchableSelect
value={formData.status}
onValueChange={(value) =>
setFormData({ ...formData, status: value as TransferOrderStatus })
}
options={[
{ label: "待處理", value: "待處理" },
{ label: "處理中", value: "處理中" },
{ label: "已完成", value: "已完成" },
{ label: "已取消", value: "已取消" },
]}
/>
</div>
)}
{/* 備註 */}
<div className="space-y-2">
<Label htmlFor="notes"></Label>
<Textarea
id="notes"
placeholder="請輸入備註(選填)"
value={formData.notes}
onChange={(e) => setFormData({ ...formData, notes: e.target.value })}
rows={3}
/>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => onOpenChange(false)}
className="button-outlined-primary"
>
</Button>
<Button onClick={handleSubmit} className="button-filled-primary">
{order ? "更新" : "新增"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}