feat(warehouse): 合併撥補單至調撥單流程並移除舊組件
This commit is contained in:
@@ -1,349 +0,0 @@
|
||||
|
||||
/**
|
||||
* 撥補單對話框元件
|
||||
* 重構後:加入驗證邏輯模組化
|
||||
*/
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,20 @@
|
||||
import { useState } from "react";
|
||||
import { Plus, Warehouse as WarehouseIcon } from 'lucide-react';
|
||||
import { SearchableSelect } from "@/Components/ui/searchable-select";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
DialogDescription,
|
||||
} from "@/Components/ui/dialog";
|
||||
import { Label } from "@/Components/ui/label";
|
||||
import { Loader2, Plus, Warehouse as WarehouseIcon } from 'lucide-react';
|
||||
import { Card, CardContent } from "@/Components/ui/card";
|
||||
import { Button } from "@/Components/ui/button";
|
||||
import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout";
|
||||
import { Head, router } from "@inertiajs/react";
|
||||
import WarehouseDialog from "@/Components/Warehouse/WarehouseDialog";
|
||||
import TransferOrderDialog from "@/Components/Warehouse/TransferOrderDialog";
|
||||
import SearchToolbar from "@/Components/shared/SearchToolbar";
|
||||
import WarehouseCard from "@/Components/Warehouse/WarehouseCard";
|
||||
import WarehouseEmptyState from "@/Components/Warehouse/WarehouseEmptyState";
|
||||
@@ -13,7 +23,6 @@ import Pagination from "@/Components/shared/Pagination";
|
||||
import { toast } from "sonner";
|
||||
import { getBreadcrumbs } from "@/utils/breadcrumb";
|
||||
import { Can } from "@/Components/Permission/Can";
|
||||
import { Card, CardContent } from "@/Components/ui/card";
|
||||
|
||||
interface PageProps {
|
||||
warehouses: {
|
||||
@@ -39,7 +48,12 @@ export default function WarehouseIndex({ warehouses, totals, filters }: PageProp
|
||||
// 對話框狀態
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||
const [editingWarehouse, setEditingWarehouse] = useState<Warehouse | null>(null);
|
||||
const [transferOrderDialogOpen, setTransferOrderDialogOpen] = useState(false);
|
||||
|
||||
// 調撥單單建立狀態
|
||||
const [isTransferCreateOpen, setIsTransferCreateOpen] = useState(false);
|
||||
const [sourceWarehouseId, setSourceWarehouseId] = useState("");
|
||||
const [targetWarehouseId, setTargetWarehouseId] = useState("");
|
||||
const [creating, setCreating] = useState(false);
|
||||
|
||||
// 搜尋處理
|
||||
const handleSearch = (term: string) => {
|
||||
@@ -93,18 +107,33 @@ export default function WarehouseIndex({ warehouses, totals, filters }: PageProp
|
||||
};
|
||||
|
||||
const handleAddTransferOrder = () => {
|
||||
setTransferOrderDialogOpen(true);
|
||||
setIsTransferCreateOpen(true);
|
||||
};
|
||||
|
||||
const handleSaveTransferOrder = (data: any) => {
|
||||
router.post(route('inventory.transfer.store'), data, {
|
||||
const handleCreateTransferOrder = () => {
|
||||
if (!sourceWarehouseId) {
|
||||
toast.error("請選擇來源倉庫");
|
||||
return;
|
||||
}
|
||||
if (!targetWarehouseId) {
|
||||
toast.error("請選擇目的倉庫");
|
||||
return;
|
||||
}
|
||||
if (sourceWarehouseId === targetWarehouseId) {
|
||||
toast.error("來源與目的倉庫不能相同");
|
||||
return;
|
||||
}
|
||||
setCreating(true);
|
||||
router.post(route('inventory.transfer.store'), {
|
||||
from_warehouse_id: sourceWarehouseId,
|
||||
to_warehouse_id: targetWarehouseId
|
||||
}, {
|
||||
onFinish: () => setCreating(false),
|
||||
onSuccess: () => {
|
||||
toast.success('撥補單已建立且庫存已轉移');
|
||||
setTransferOrderDialogOpen(false);
|
||||
},
|
||||
onError: (errors) => {
|
||||
toast.error('建立撥補單失敗');
|
||||
console.error(errors);
|
||||
setIsTransferCreateOpen(false);
|
||||
setSourceWarehouseId("");
|
||||
setTargetWarehouseId("");
|
||||
toast.success('調撥單已建立');
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -170,7 +199,7 @@ export default function WarehouseIndex({ warehouses, totals, filters }: PageProp
|
||||
className="flex-1 md:flex-initial button-outlined-primary"
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
新增撥補單
|
||||
新增調撥單
|
||||
</Button>
|
||||
</Can>
|
||||
<Can permission="warehouses.create">
|
||||
@@ -222,15 +251,52 @@ export default function WarehouseIndex({ warehouses, totals, filters }: PageProp
|
||||
onDelete={handleDeleteWarehouse}
|
||||
/>
|
||||
|
||||
{/* 撥補單對話框 */}
|
||||
<TransferOrderDialog
|
||||
open={transferOrderDialogOpen}
|
||||
onOpenChange={setTransferOrderDialogOpen}
|
||||
order={null}
|
||||
onSave={handleSaveTransferOrder}
|
||||
warehouses={warehouses.data}
|
||||
{/* 調撥單建立對話框 */}
|
||||
<Dialog open={isTransferCreateOpen} onOpenChange={setIsTransferCreateOpen}>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>建立新調撥單</DialogTitle>
|
||||
<DialogDescription>
|
||||
請選擇來源倉庫與目的倉庫。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label>來源倉庫</Label>
|
||||
<SearchableSelect
|
||||
value={sourceWarehouseId}
|
||||
onValueChange={setSourceWarehouseId}
|
||||
options={warehouses.data.map((w: any) => ({ label: w.name, value: w.id.toString() }))}
|
||||
placeholder="請選擇來源倉庫"
|
||||
className="h-9"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>目的倉庫</Label>
|
||||
<SearchableSelect
|
||||
value={targetWarehouseId}
|
||||
onValueChange={setTargetWarehouseId}
|
||||
options={warehouses.data
|
||||
.filter((w: any) => w.id.toString() !== sourceWarehouseId)
|
||||
.map((w: any) => ({ label: w.name, value: w.id.toString() }))
|
||||
}
|
||||
placeholder="請選擇目的倉庫"
|
||||
className="h-9"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" className="button-outlined-primary" onClick={() => setIsTransferCreateOpen(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={handleCreateTransferOrder} className="button-filled-primary" disabled={creating || !sourceWarehouseId || !targetWarehouseId}>
|
||||
{creating && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
新增
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</AuthenticatedLayout>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user