feat(warehouse): 合併撥補單至調撥單流程並移除舊組件

This commit is contained in:
2026-02-02 10:07:36 +08:00
parent 313b95ceb9
commit 1748eb007e
2 changed files with 88 additions and 371 deletions

View File

@@ -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>
);
}

View File

@@ -1,10 +1,20 @@
import { useState } from "react"; 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 { Button } from "@/Components/ui/button";
import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout"; import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout";
import { Head, router } from "@inertiajs/react"; import { Head, router } from "@inertiajs/react";
import WarehouseDialog from "@/Components/Warehouse/WarehouseDialog"; import WarehouseDialog from "@/Components/Warehouse/WarehouseDialog";
import TransferOrderDialog from "@/Components/Warehouse/TransferOrderDialog";
import SearchToolbar from "@/Components/shared/SearchToolbar"; import SearchToolbar from "@/Components/shared/SearchToolbar";
import WarehouseCard from "@/Components/Warehouse/WarehouseCard"; import WarehouseCard from "@/Components/Warehouse/WarehouseCard";
import WarehouseEmptyState from "@/Components/Warehouse/WarehouseEmptyState"; import WarehouseEmptyState from "@/Components/Warehouse/WarehouseEmptyState";
@@ -13,7 +23,6 @@ import Pagination from "@/Components/shared/Pagination";
import { toast } from "sonner"; import { toast } from "sonner";
import { getBreadcrumbs } from "@/utils/breadcrumb"; import { getBreadcrumbs } from "@/utils/breadcrumb";
import { Can } from "@/Components/Permission/Can"; import { Can } from "@/Components/Permission/Can";
import { Card, CardContent } from "@/Components/ui/card";
interface PageProps { interface PageProps {
warehouses: { warehouses: {
@@ -39,7 +48,12 @@ export default function WarehouseIndex({ warehouses, totals, filters }: PageProp
// 對話框狀態 // 對話框狀態
const [isDialogOpen, setIsDialogOpen] = useState(false); const [isDialogOpen, setIsDialogOpen] = useState(false);
const [editingWarehouse, setEditingWarehouse] = useState<Warehouse | null>(null); 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) => { const handleSearch = (term: string) => {
@@ -93,18 +107,33 @@ export default function WarehouseIndex({ warehouses, totals, filters }: PageProp
}; };
const handleAddTransferOrder = () => { const handleAddTransferOrder = () => {
setTransferOrderDialogOpen(true); setIsTransferCreateOpen(true);
}; };
const handleSaveTransferOrder = (data: any) => { const handleCreateTransferOrder = () => {
router.post(route('inventory.transfer.store'), data, { 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: () => { onSuccess: () => {
toast.success('撥補單已建立且庫存已轉移'); setIsTransferCreateOpen(false);
setTransferOrderDialogOpen(false); setSourceWarehouseId("");
}, setTargetWarehouseId("");
onError: (errors) => { toast.success('調撥單已建立');
toast.error('建立撥補單失敗');
console.error(errors);
} }
}); });
}; };
@@ -170,7 +199,7 @@ export default function WarehouseIndex({ warehouses, totals, filters }: PageProp
className="flex-1 md:flex-initial button-outlined-primary" className="flex-1 md:flex-initial button-outlined-primary"
> >
<Plus className="mr-2 h-4 w-4" /> <Plus className="mr-2 h-4 w-4" />
調
</Button> </Button>
</Can> </Can>
<Can permission="warehouses.create"> <Can permission="warehouses.create">
@@ -222,15 +251,52 @@ export default function WarehouseIndex({ warehouses, totals, filters }: PageProp
onDelete={handleDeleteWarehouse} onDelete={handleDeleteWarehouse}
/> />
{/* 撥補單對話框 */} {/* 調撥單建立對話框 */}
<TransferOrderDialog <Dialog open={isTransferCreateOpen} onOpenChange={setIsTransferCreateOpen}>
open={transferOrderDialogOpen} <DialogContent className="sm:max-w-[425px]">
onOpenChange={setTransferOrderDialogOpen} <DialogHeader>
order={null} <DialogTitle>調</DialogTitle>
onSave={handleSaveTransferOrder} <DialogDescription>
warehouses={warehouses.data}
</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>
<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> </AuthenticatedLayout>
); );
} }