feat(Inventory): 實作批號溯源完整功能與 UI 呈現,包含文字敘述卡片與更完整的關聯屬性

This commit is contained in:
2026-02-26 10:39:24 +08:00
parent 63e4f88a14
commit f960aaaeb2
16 changed files with 1085 additions and 694 deletions

View File

@@ -13,14 +13,8 @@ import {
TableRow,
} from "@/Components/ui/table";
import { StatusBadge } from "@/Components/shared/StatusBadge";
import { Checkbox } from "@/Components/ui/checkbox";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/Components/ui/dialog";
import { SearchableSelect } from "@/Components/ui/searchable-select";
import {
AlertDialog,
AlertDialogAction,
@@ -39,7 +33,7 @@ import {
SelectTrigger,
SelectValue,
} from "@/Components/ui/select";
import { Plus, Save, Trash2, ArrowLeft, CheckCircle, Package, ArrowLeftRight, Printer, Search, Truck, PackageCheck } from "lucide-react";
import { Plus, Save, Trash2, ArrowLeft, CheckCircle, Package, ArrowLeftRight, Printer, Truck, PackageCheck } from "lucide-react";
import { toast } from "sonner";
import axios from "axios";
import { Can } from '@/Components/Permission/Can';
@@ -115,20 +109,20 @@ export default function Show({ order, transitWarehouses = [] }: { order: any; tr
}
}, [order]);
const canEdit = can('inventory_transfer.edit');
const isReadOnly = (order.status !== 'draft' || !canEdit);
const isItemsReadOnly = isReadOnly || !!order.requisition;
const isVending = order.to_warehouse_type === 'vending';
// Product Selection
const [isProductDialogOpen, setIsProductDialogOpen] = useState(false);
const [availableInventory, setAvailableInventory] = useState<any[]>([]);
const [loadingInventory, setLoadingInventory] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [selectedInventory, setSelectedInventory] = useState<string[]>([]); // product_id-batch
useEffect(() => {
if (isProductDialogOpen) {
if (!isItemsReadOnly && order.from_warehouse_id) {
loadInventory();
setSelectedInventory([]);
setSearchQuery('');
}
}, [isProductDialogOpen]);
}, [isItemsReadOnly, order.from_warehouse_id]);
const loadInventory = async () => {
setLoadingInventory(true);
@@ -143,57 +137,22 @@ export default function Show({ order, transitWarehouses = [] }: { order: any; tr
}
};
const toggleSelect = (key: string) => {
setSelectedInventory(prev =>
prev.includes(key) ? prev.filter(k => k !== key) : [...prev, key]
);
};
const toggleSelectAll = () => {
const filtered = availableInventory.filter(inv =>
inv.product_name.toLowerCase().includes(searchQuery.toLowerCase()) ||
inv.product_code.toLowerCase().includes(searchQuery.toLowerCase()) ||
(inv.product_barcode && inv.product_barcode.toLowerCase().includes(searchQuery.toLowerCase()))
);
const filteredKeys = filtered.map(inv => `${inv.product_id}-${inv.batch_number}`);
if (filteredKeys.length > 0 && filteredKeys.every(k => selectedInventory.includes(k))) {
setSelectedInventory(prev => prev.filter(k => !filteredKeys.includes(k)));
} else {
setSelectedInventory(prev => Array.from(new Set([...prev, ...filteredKeys])));
}
};
const handleAddSelected = () => {
if (selectedInventory.length === 0) return;
const newItems = [...items];
let addedCount = 0;
availableInventory.forEach(inv => {
const key = `${inv.product_id}-${inv.batch_number}`;
if (selectedInventory.includes(key)) {
newItems.push({
product_id: inv.product_id,
product_name: inv.product_name,
product_code: inv.product_code,
batch_number: inv.batch_number,
expiry_date: inv.expiry_date,
unit: inv.unit_name,
quantity: 1,
max_quantity: inv.quantity,
notes: "",
});
addedCount++;
const handleAddItem = () => {
setItems([
...items,
{
product_id: "",
product_name: "",
product_code: "",
batch_number: "",
expiry_date: null,
unit: "",
quantity: "",
max_quantity: 0,
position: "",
notes: ""
}
});
setItems(newItems);
setIsProductDialogOpen(false);
if (addedCount > 0) {
toast.success(`已成功加入 ${addedCount} 個項目`);
}
]);
};
const handleUpdateItem = (index: number, field: string, value: any) => {
@@ -210,11 +169,16 @@ export default function Show({ order, transitWarehouses = [] }: { order: any; tr
const handleSave = async () => {
setIsSaving(true);
try {
await router.put(route('inventory.transfer.update', [order.id]), {
items: items,
const payload: any = {
remarks: remarks,
transit_warehouse_id: transitWarehouseId || '',
}, {
};
if (!order.requisition) {
payload.items = items;
}
await router.put(route('inventory.transfer.update', [order.id]), payload, {
onSuccess: () => { },
onError: () => toast.error("儲存失敗,請檢查輸入"),
});
@@ -223,15 +187,19 @@ export default function Show({ order, transitWarehouses = [] }: { order: any; tr
}
};
// 確認出貨 / 確認過帳(無在途倉)
// 確認出貨 / 確認過帳(無在途倉)
const handlePost = () => {
router.put(route('inventory.transfer.update', [order.id]), {
const payload: any = {
action: 'post',
transit_warehouse_id: transitWarehouseId || '',
items: items,
remarks: remarks,
}, {
};
if (!order.requisition) {
payload.items = items;
}
router.put(route('inventory.transfer.update', [order.id]), payload, {
onSuccess: () => {
setIsPostDialogOpen(false);
},
@@ -267,10 +235,7 @@ export default function Show({ order, transitWarehouses = [] }: { order: any; tr
});
};
const canEdit = can('inventory_transfer.edit');
const isReadOnly = (order.status !== 'draft' || !canEdit);
const isItemsReadOnly = isReadOnly || !!order.requisition;
const isVending = order.to_warehouse_type === 'vending';
// 狀態 Badge 渲染
const renderStatusBadge = () => {
@@ -579,146 +544,10 @@ export default function Show({ order, transitWarehouses = [] }: { order: any; tr
onOpenChange={setIsImportDialogOpen}
orderId={order.id}
/>
<Dialog open={isProductDialogOpen} onOpenChange={setIsProductDialogOpen}>
<DialogTrigger asChild>
<Button variant="outline" className="button-outlined-primary">
<Plus className="h-4 w-4 mr-2" />
</Button>
</DialogTrigger>
<DialogContent className="max-w-4xl max-h-[85vh] flex flex-col p-6">
<DialogHeader className="flex flex-row items-center justify-between space-y-0 pb-4">
<DialogTitle className="text-xl"> ({order.from_warehouse_name})</DialogTitle>
<div className="relative w-72">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-grey-3" />
<Input
placeholder="搜尋品名、代號或條碼..."
className="pl-9 h-9 border-2 border-grey-3 focus:ring-primary-main"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
</DialogHeader>
<div className="flex-1 overflow-auto pr-1">
{loadingInventory ? (
<div className="text-center py-12">
<Package className="h-10 w-10 animate-bounce mx-auto text-gray-300 mb-2" />
<p className="text-grey-2 text-sm">...</p>
</div>
) : (
<div className="border rounded-lg overflow-hidden">
<Table>
<TableHeader className="bg-gray-50/80 sticky top-0 z-10 shadow-sm">
<TableRow>
<TableHead className="w-[50px] text-center">
<Checkbox
checked={availableInventory.length > 0 && (() => {
const filtered = availableInventory.filter(inv =>
inv.product_name.toLowerCase().includes(searchQuery.toLowerCase()) ||
inv.product_code.toLowerCase().includes(searchQuery.toLowerCase()) ||
(inv.product_barcode && inv.product_barcode.toLowerCase().includes(searchQuery.toLowerCase()))
);
const filteredKeys = filtered.map(inv => `${inv.product_id}-${inv.batch_number}`);
return filteredKeys.length > 0 && filteredKeys.every(k => selectedInventory.includes(k));
})()}
onCheckedChange={() => toggleSelectAll()}
/>
</TableHead>
<TableHead className="font-medium text-grey-600"> / </TableHead>
<TableHead className="font-medium text-grey-600"></TableHead>
<TableHead className="font-medium text-grey-600"></TableHead>
<TableHead className="text-right font-medium text-grey-600 pr-6"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{(() => {
const filtered = availableInventory.filter(inv =>
inv.product_name.toLowerCase().includes(searchQuery.toLowerCase()) ||
inv.product_code.toLowerCase().includes(searchQuery.toLowerCase()) ||
(inv.product_barcode && inv.product_barcode.toLowerCase().includes(searchQuery.toLowerCase()))
);
if (filtered.length === 0) {
return (
<TableRow>
<TableCell colSpan={5} className="text-center py-12 text-grey-3 italic font-medium">
{searchQuery ? `找不到與 "${searchQuery}" 相關的商品` : '尚無庫存資料'}
</TableCell>
</TableRow>
);
}
return filtered.map((inv) => {
const key = `${inv.product_id}-${inv.batch_number}`;
const isSelected = selectedInventory.includes(key);
return (
<TableRow
key={key}
className={`hover:bg-primary-lightest/20 cursor-pointer transition-colors ${isSelected ? 'bg-primary-lightest/40' : ''}`}
onClick={() => toggleSelect(key)}
>
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
<Checkbox
checked={isSelected}
onCheckedChange={() => toggleSelect(key)}
/>
</TableCell>
<TableCell className="py-3">
<div className="flex flex-col">
<span className="font-semibold text-grey-0">{inv.product_name}</span>
<span className="text-xs text-grey-2 font-mono">{inv.product_code}</span>
</div>
</TableCell>
<TableCell className="text-sm font-mono text-grey-2">{inv.batch_number || '-'}</TableCell>
<TableCell className="text-sm font-mono text-grey-2">{inv.expiry_date || '-'}</TableCell>
<TableCell className="text-right font-bold text-primary-main pr-6">{inv.quantity} {inv.unit_name}</TableCell>
</TableRow>
);
});
})()}
</TableBody>
</Table>
</div>
)}
</div>
<div className="mt-6 flex items-center justify-between border-t pt-4">
<div className="flex items-center gap-3">
<div className="px-3 py-1 bg-primary-lightest/50 border border-primary-light/20 rounded-full text-sm font-medium text-primary-main animate-in zoom-in duration-200">
{selectedInventory.length}
</div>
{selectedInventory.length > 0 && (
<Button
variant="ghost"
size="sm"
className="text-grey-3 hover:text-red-500 hover:bg-red-50 text-xs px-2 h-7"
onClick={() => setSelectedInventory([])}
>
</Button>
)}
</div>
<div className="flex gap-2">
<Button
variant="outline"
className="button-outlined-primary w-24"
onClick={() => setIsProductDialogOpen(false)}
>
</Button>
<Button
className="button-filled-primary min-w-32"
disabled={selectedInventory.length === 0}
onClick={handleAddSelected}
>
</Button>
</div>
</div>
</DialogContent>
</Dialog>
<Button variant="outline" className="button-outlined-primary" onClick={handleAddItem}>
<Plus className="h-4 w-4 mr-2" />
</Button>
</div>
)}
</div>
@@ -752,10 +581,41 @@ export default function Show({ order, transitWarehouses = [] }: { order: any; tr
<TableRow key={index}>
<TableCell className="text-center text-gray-500 font-medium">{index + 1}</TableCell>
<TableCell className="py-3">
<div className="flex flex-col">
<span className="font-semibold text-gray-900">{item.product_name}</span>
<span className="text-xs text-gray-500 font-mono">{item.product_code}</span>
</div>
{isItemsReadOnly || item.product_id ? (
<div className="flex flex-col">
<span className="font-semibold text-gray-900">{item.product_name}</span>
<span className="text-xs text-gray-500 font-mono">{item.product_code}</span>
</div>
) : (
<SearchableSelect
value={item.product_id ? `${item.product_id}|${item.batch_number || ''}` : ""}
onValueChange={(val) => {
const [pid, batch] = val.split('|');
const inv = availableInventory.find(i => String(i.product_id) === pid && (i.batch_number || '') === batch);
if (inv) {
const newItems = [...items];
newItems[index] = {
...newItems[index],
product_id: inv.product_id,
product_name: inv.product_name,
product_code: inv.product_code,
batch_number: inv.batch_number,
expiry_date: inv.expiry_date,
unit: inv.unit_name,
max_quantity: inv.quantity,
quantity: newItems[index].quantity || 1
};
setItems(newItems);
}
}}
options={availableInventory.map(inv => ({
label: `${inv.product_code} - ${inv.product_name} ${inv.batch_number ? `(批號: ${inv.batch_number})` : ''} - 庫存: ${inv.quantity}`,
value: `${inv.product_id}|${inv.batch_number || ''}`
}))}
placeholder={loadingInventory ? "載入庫存中..." : "搜尋名稱或代號選擇庫存"}
className="w-full min-w-[200px]"
/>
)}
</TableCell>
<TableCell className="text-sm font-mono">
<div>{item.batch_number || '-'}</div>
@@ -766,7 +626,7 @@ export default function Show({ order, transitWarehouses = [] }: { order: any; tr
)}
</TableCell>
<TableCell className="text-right font-semibold text-primary-main">
{item.max_quantity} {item.unit || item.unit_name}
{item.product_id ? `${item.max_quantity} ${item.unit || item.unit_name || ''}` : '-'}
</TableCell>
<TableCell className="px-1 py-3">
{isItemsReadOnly ? (