From 7619dc24f79dbf5159ae99c7dd6d0e450ac85cbc Mon Sep 17 00:00:00 2001 From: sky121113 Date: Thu, 29 Jan 2026 14:37:21 +0800 Subject: [PATCH] =?UTF-8?q?feat(inventory):=20=E7=B5=B1=E4=B8=80=E5=BA=AB?= =?UTF-8?q?=E5=AD=98=E8=AA=BF=E6=95=B4=E8=88=87=E8=AA=BF=E6=92=A5=E6=A8=A1?= =?UTF-8?q?=E7=B5=84=20UI=EF=BC=8C=E5=AF=A6=E4=BD=9C=E5=A4=9A=E9=81=B8?= =?UTF-8?q?=E3=80=81=E6=90=9C=E5=B0=8B=E8=88=87=E6=98=8E=E7=B4=B0=E6=AC=84?= =?UTF-8?q?=E4=BD=8D=E9=87=8D=E6=A7=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- resources/js/Pages/Inventory/Adjust/Index.tsx | 71 +- resources/js/Pages/Inventory/Adjust/Show.tsx | 448 ++++++++----- resources/js/Pages/Inventory/Count/Show.tsx | 16 - .../js/Pages/Inventory/Transfer/Index.tsx | 424 ++++++++---- .../js/Pages/Inventory/Transfer/Show.tsx | 630 ++++++++++++------ 5 files changed, 1013 insertions(+), 576 deletions(-) diff --git a/resources/js/Pages/Inventory/Adjust/Index.tsx b/resources/js/Pages/Inventory/Adjust/Index.tsx index 2cc05af..4a69cf1 100644 --- a/resources/js/Pages/Inventory/Adjust/Index.tsx +++ b/resources/js/Pages/Inventory/Adjust/Index.tsx @@ -15,7 +15,6 @@ import { Dialog, DialogContent, DialogDescription, - DialogFooter, DialogHeader, DialogTitle, } from "@/Components/ui/dialog"; @@ -138,7 +137,7 @@ export default function Index({ docs, warehouses, filters }: { docs: DocsPaginat const { data, setData, post, processing, reset } = useForm({ count_doc_id: null as string | null, warehouse_id: '', - reason: '', + reason: '手動調整庫存', remarks: '', }); @@ -375,13 +374,16 @@ export default function Index({ docs, warehouses, filters }: { docs: DocsPaginat
{/* Option 1: Scan/Select from Count Docs */} -
- +
+
- + { setScanSearch(e.target.value); @@ -390,26 +392,26 @@ export default function Index({ docs, warehouses, filters }: { docs: DocsPaginat />
-
+
{loadingPending ? ( -
載入中...
+
載入中...
) : pendingCounts.length === 0 ? ( -
- 查無可供盤調的盤點單 (需為已完成狀態) +
+ 查無可供盤調的盤點單 (需為已核准狀態)
) : ( -
+
{pendingCounts.map((c: any) => (
handleCreate(c.id)} >
-

{c.doc_no}

-

{c.warehouse_name} | 完成於: {c.completed_at}

+

{c.doc_no}

+

{c.warehouse_name} | 完成於: {c.completed_at}

-
@@ -419,47 +421,48 @@ export default function Index({ docs, warehouses, filters }: { docs: DocsPaginat
-
-
-
+
+
+ +
{/* Option 2: Manual (Optional, though less common in this flow) */} -
- +
+
- + ({ value: w.id, label: w.name }))} value={data.warehouse_id} onValueChange={(val) => setData('warehouse_id', val)} placeholder="選擇倉庫" + className="h-9" />
- + setData('reason', e.target.value)} - className="h-10" + className="h-9" />
+
+ + +
- - - - - diff --git a/resources/js/Pages/Inventory/Adjust/Show.tsx b/resources/js/Pages/Inventory/Adjust/Show.tsx index a7489d1..966887f 100644 --- a/resources/js/Pages/Inventory/Adjust/Show.tsx +++ b/resources/js/Pages/Inventory/Adjust/Show.tsx @@ -11,6 +11,7 @@ import { import { Button } from "@/Components/ui/button"; import { Input } from "@/Components/ui/input"; import { Badge } from "@/Components/ui/badge"; +import { Checkbox } from "@/Components/ui/checkbox"; import { AlertDialog, AlertDialogAction, @@ -23,16 +24,18 @@ import { AlertDialogTrigger, } from "@/Components/ui/alert-dialog"; import { Label } from "@/Components/ui/label"; -import { Save, CheckCircle, Trash2, ArrowLeft, Plus, X, Search, ClipboardCheck } from "lucide-react"; +import { Save, CheckCircle, Trash2, ArrowLeft, Plus, ClipboardCheck, Package, Search } from "lucide-react"; import { useState, useEffect } from 'react'; import { Dialog, DialogContent, DialogHeader, DialogTitle, + DialogTrigger, } from "@/Components/ui/dialog"; import axios from 'axios'; import { Can } from '@/Components/Permission/Can'; +import { toast } from 'sonner'; interface AdjItem { id?: string; @@ -41,7 +44,7 @@ interface AdjItem { product_code: string; batch_number: string | null; unit: string; - qty_before: number; + qty_before: number | string; adjust_qty: number | string; notes: string; } @@ -72,33 +75,96 @@ export default function Show({ doc }: { auth: any, doc: AdjDoc }) { action: 'save', }); + // Product Selection State + const [isProductDialogOpen, setIsProductDialogOpen] = useState(false); + const [availableInventory, setAvailableInventory] = useState([]); + const [loadingInventory, setLoadingInventory] = useState(false); + const [searchQuery, setSearchQuery] = useState(''); + const [selectedInventory, setSelectedInventory] = useState([]); // product_id-batch + const [isPostDialogOpen, setIsPostDialogOpen] = useState(false); + const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); - // Helper to add new item - const addItem = (product: any, batchNumber: string | null) => { - // Check if exists - const exists = data.items.find(i => - i.product_id === String(product.id) && - i.batch_number === batchNumber - ); - - if (exists) { - alert('此商品與批號已在列表中'); - return; + useEffect(() => { + if (isProductDialogOpen) { + loadInventory(); + setSelectedInventory([]); // Reset selection when opening + setSearchQuery(''); // Reset search when opening } + }, [isProductDialogOpen]); - setData('items', [ - ...data.items, - { - product_id: String(product.id), - product_name: product.name, - product_code: product.code, - unit: product.unit, - batch_number: batchNumber, - qty_before: product.qty || 0, // Not fetched dynamically for now, or could fetch via API - adjust_qty: 0, - notes: '', + const loadInventory = async () => { + setLoadingInventory(true); + try { + const response = await axios.get(route('api.warehouses.inventories', doc.warehouse_id)); + setAvailableInventory(response.data); + } catch (error) { + console.error("Failed to load inventory", error); + toast.error("無法載入庫存資料"); + } finally { + setLoadingInventory(false); + } + }; + + 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()) + ); + 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]))); + } + }; + + // Helper to add selected items to the main list + const handleAddSelected = () => { + if (selectedInventory.length === 0) return; + + const newItems = [...data.items]; + let addedCount = 0; + + availableInventory.forEach(inv => { + const key = `${inv.product_id}-${inv.batch_number}`; + if (selectedInventory.includes(key)) { + // Check if already exists + const exists = newItems.find((i: any) => + i.product_id === String(inv.product_id) && + i.batch_number === inv.batch_number + ); + + if (!exists) { + newItems.push({ + product_id: String(inv.product_id), + product_name: inv.product_name, + product_code: inv.product_code, + unit: inv.unit_name, + batch_number: inv.batch_number, + qty_before: inv.quantity || 0, + adjust_qty: 0, + notes: '', + }); + addedCount++; + } } - ]); + }); + + setData('items', newItems); + setIsProductDialogOpen(false); + + if (addedCount > 0) { + toast.success(`已成功加入 ${addedCount} 個項目`); + } else { + toast.info("選取的商品已在清單中"); + } }; const removeItem = (index: number) => { @@ -117,23 +183,31 @@ export default function Show({ doc }: { auth: any, doc: AdjDoc }) { setData('action', 'save'); put(route('inventory.adjust.update', [doc.id]), { preserveScroll: true, + onSuccess: () => toast.success("草稿儲存成功"), }); }; const handlePost = () => { if (data.items.length === 0) { - alert('請至少加入一個調整項目'); + toast.error('請至少加入一個調整項目'); return; } - router.visit(route('inventory.adjust.update', [doc.id]), { - method: 'put', - data: { ...data, action: 'post' } as any, + router.put(route('inventory.adjust.update', [doc.id]), { + ...data, + action: 'post' + } as any, { + onSuccess: () => { + setIsPostDialogOpen(false); + toast.success("盤調單過帳成功"); + } }); }; const handleDelete = () => { - destroy(route('inventory.adjust.destroy', [doc.id])); + destroy(route('inventory.adjust.destroy', [doc.id]), { + onSuccess: () => toast.success("盤調單已刪除"), + }); }; return ( @@ -166,9 +240,9 @@ export default function Show({ doc }: { auth: any, doc: AdjDoc }) { 盤調單: {doc.doc_no} {isDraft ? ( - 草稿 + 草稿 ) : ( - 已過帳 + 已過帳 )}

@@ -191,7 +265,7 @@ export default function Show({ doc }: { auth: any, doc: AdjDoc }) {

{isDraft && ( - + - + + + + + + + 確定要過帳嗎? + + 過帳後庫存將立即根據調整數量進行增減,且無法再修改此盤調單。 + + + + 取消 + 確認過帳 + + + )}
@@ -242,7 +331,7 @@ export default function Show({ doc }: { auth: any, doc: AdjDoc }) { {/* Header Fields - Inline */}
- + {isDraft ? (
- + {isDraft ? (
-

調整項目

+
+

調整項目

+

+ 請輸入各項商品的實盤與帳面之差異數量。正數為增加,負數為減少。 +

+
{isDraft && !doc.count_doc_id && ( - addItem(product, batch)} - /> + + + + + + + 選擇倉庫商品 ({doc.warehouse_name}) +
+ + setSearchQuery(e.target.value)} + /> +
+
+
+ {loadingInventory ? ( +
+ +

庫存資料載入中...

+
+ ) : ( +
+ + + + + 0 && selectedInventory.length === availableInventory.length} + onCheckedChange={() => toggleSelectAll()} + /> + + 商品代號 + 品名 + 批號 + 現有庫存 + + + + {(() => { + const filtered = availableInventory.filter(inv => + inv.product_name.toLowerCase().includes(searchQuery.toLowerCase()) || + inv.product_code.toLowerCase().includes(searchQuery.toLowerCase()) + ); + + if (filtered.length === 0) { + return ( + + + {searchQuery ? `找不到與 "${searchQuery}" 相關的商品` : '尚無庫存資料'} + + + ); + } + + return filtered.map((inv) => { + const key = `${inv.product_id}-${inv.batch_number}`; + const isSelected = selectedInventory.includes(key); + return ( + toggleSelect(key)} + > + e.stopPropagation()}> + toggleSelect(key)} + /> + + {inv.product_code} + {inv.product_name} + {inv.batch_number || '-'} + {inv.quantity} {inv.unit_name} + + ); + }); + })()} + +
+
+ )} +
+
+
+
+ 已選取 {selectedInventory.length} 項商品 +
+ {selectedInventory.length > 0 && ( + + )} +
+
+ + +
+
+
+
)}
@@ -286,7 +499,7 @@ export default function Show({ doc }: { auth: any, doc: AdjDoc }) { # - 商品資訊 + 商品名稱 / 代號 批號 單位 調整前庫存 @@ -310,24 +523,28 @@ export default function Show({ doc }: { auth: any, doc: AdjDoc }) { > {index + 1} -
{item.product_name}
-
{item.product_code}
+
+ {item.product_name} + {item.product_code} +
- {item.batch_number || '-'} + {item.batch_number || '-'} {item.unit} {item.qty_before} {isDraft ? ( - updateItem(index, 'adjust_qty', e.target.value)} - /> +
+ updateItem(index, 'adjust_qty', e.target.value)} + /> +
) : ( - 0 ? 'text-green-600' : 'text-red-600'}`}> + 0 ? 'text-green-600' : Number(item.adjust_qty) < 0 ? 'text-red-600' : 'text-gray-600'}`}> {Number(item.adjust_qty) > 0 ? '+' : ''}{item.adjust_qty} )} @@ -335,7 +552,7 @@ export default function Show({ doc }: { auth: any, doc: AdjDoc }) { {isDraft ? ( updateItem(index, 'notes', e.target.value)} placeholder="備註..." @@ -352,7 +569,7 @@ export default function Show({ doc }: { auth: any, doc: AdjDoc }) { className="h-8 w-8 text-red-400 hover:text-red-600 hover:bg-red-50 p-0" onClick={() => removeItem(index)} > - + )} @@ -368,110 +585,3 @@ export default function Show({ doc }: { auth: any, doc: AdjDoc }) { ); } - -// Simple internal component for product search -function ProductSearchDialog({ onSelect }: { warehouseId: string, onSelect: (p: any, b: string | null) => void }) { - const [search, setSearch] = useState(''); - const [results, setResults] = useState([]); - const [loading, setLoading] = useState(false); - const [open, setOpen] = useState(false); - - // Debounce search - useEffect(() => { - if (!search) { - setResults([]); - return; - } - const timer = setTimeout(() => { - fetchProducts(); - }, 500); - return () => clearTimeout(timer); - }, [search]); - - const fetchProducts = async () => { - setLoading(true); - try { - // Using existing API logic from Goods Receipts or creating a flexible one - // Using count docs logic for now if specific endpoint not available, - // but `goods-receipts.search-products` is a good bet for general product search. - const res = await axios.get(route('goods-receipts.search-products'), { params: { query: search } }); - setResults(res.data); - } catch (e) { - console.error(e); - } finally { - setLoading(false); - } - }; - - return ( - - - - - - - 搜尋並加入商品 - - -
-
- - setSearch(e.target.value)} - autoFocus - /> -
- -
- {loading ? ( -
-
- 搜尋中... -
- ) : results.length === 0 ? ( -
- -

請輸入商品關鍵字開始搜尋

-
- ) : ( -
- {results.map(product => ( -
{ - onSelect(product, null); - setOpen(false); - setSearch(''); - setResults([]); - }} - > -
-
{product.name}
-
{product.code}
-
-
- {product.unit || '單位'} - 點擊加入 -
-
- ))} -
- )} -
-
-
-
- ) -} diff --git a/resources/js/Pages/Inventory/Count/Show.tsx b/resources/js/Pages/Inventory/Count/Show.tsx index 2389270..8d50505 100644 --- a/resources/js/Pages/Inventory/Count/Show.tsx +++ b/resources/js/Pages/Inventory/Count/Show.tsx @@ -237,7 +237,6 @@ export default function Show({ doc }: any) { 差異 單位 備註 - {!isCompleted && }
@@ -303,21 +302,6 @@ export default function Show({ doc }: any) { /> )} - {!isCompleted && ( - - - - ) - } ); })} diff --git a/resources/js/Pages/Inventory/Transfer/Index.tsx b/resources/js/Pages/Inventory/Transfer/Index.tsx index f3dbc84..0343e7c 100644 --- a/resources/js/Pages/Inventory/Transfer/Index.tsx +++ b/resources/js/Pages/Inventory/Transfer/Index.tsx @@ -1,7 +1,9 @@ -import { useState, useEffect } from "react"; +import { useState, useCallback, useEffect } from "react"; import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout"; import { Head, Link, router } from "@inertiajs/react"; +import { debounce } from "lodash"; +import { SearchableSelect } from "@/Components/ui/searchable-select"; import { Table, TableBody, @@ -20,41 +22,94 @@ import { DialogTitle, DialogTrigger, DialogFooter, + DialogDescription, } from "@/Components/ui/dialog"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/Components/ui/select"; import { Label } from "@/Components/ui/label"; import { Plus, Search, - FileText, ArrowLeftRight, Loader2, + Eye, + Pencil, + Trash2, + X, } from "lucide-react"; -import { format } from "date-fns"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/Components/ui/alert-dialog"; import Pagination from "@/Components/shared/Pagination"; import { toast } from "sonner"; +import { Can } from '@/Components/Permission/Can'; -export default function Index({ auth, orders, warehouses, filters }) { - const [searchQuery, setSearchQuery] = useState(""); +export default function Index({ warehouses, orders, filters }: any) { + const [searchTerm, setSearchTerm] = useState(filters.search || ""); + const [warehouseFilter, setWarehouseFilter] = useState(filters.warehouse_id || "all"); + const [perPage, setPerPage] = useState(filters.per_page || "10"); + + // Sync state with props + useEffect(() => { + setSearchTerm(filters.search || ""); + setWarehouseFilter(filters.warehouse_id || "all"); + setPerPage(filters.per_page || "10"); + }, [filters]); // Create Dialog State const [isCreateOpen, setIsCreateOpen] = useState(false); const [sourceWarehouseId, setSourceWarehouseId] = useState(""); const [targetWarehouseId, setTargetWarehouseId] = useState(""); const [creating, setCreating] = useState(false); + const [deleteId, setDeleteId] = useState(null); - // Handle warehouse filter change - const handleFilterChange = (value) => { - router.get(route('inventory.transfer.index'), { warehouse_id: value }, { - preserveState: true, - replace: true, - }); + // Debounced Search Handler + const debouncedSearch = useCallback( + debounce((term: string, warehouse: string) => { + router.get( + route('inventory.transfer.index'), + { ...filters, search: term, warehouse_id: warehouse === "all" ? "" : warehouse }, + { preserveState: true, replace: true, preserveScroll: true } + ); + }, 500), + [filters] + ); + + const handleSearchChange = (term: string) => { + setSearchTerm(term); + debouncedSearch(term, warehouseFilter); + }; + + const handleFilterChange = (value: string) => { + setWarehouseFilter(value); + router.get( + route('inventory.transfer.index'), + { ...filters, warehouse_id: value === "all" ? "" : value }, + { preserveState: false, replace: true, preserveScroll: true } + ); + }; + + const handleClearSearch = () => { + setSearchTerm(""); + router.get( + route('inventory.transfer.index'), + { ...filters, search: "", warehouse_id: warehouseFilter === "all" ? "" : warehouseFilter }, + { preserveState: true, replace: true, preserveScroll: true } + ); + }; + + const handlePerPageChange = (value: string) => { + setPerPage(value); + router.get( + route('inventory.transfer.index'), + { ...filters, per_page: value }, + { preserveState: false, replace: true, preserveScroll: true } + ); }; const handleCreate = () => { @@ -84,12 +139,28 @@ export default function Index({ auth, orders, warehouses, filters }) { }); }; - const getStatusBadge = (status) => { + const confirmDelete = (id: string) => { + setDeleteId(id); + }; + + const handleDelete = () => { + if (deleteId) { + router.delete(route('inventory.transfer.destroy', [deleteId]), { + onSuccess: () => { + setDeleteId(null); + toast.success("已成功刪除"); + }, + onError: () => setDeleteId(null), + }); + } + }; + + const getStatusBadge = (status: string) => { switch (status) { case 'draft': return 草稿; case 'completed': - return 已完成; + return 已完成; case 'voided': return 已作廢; default: @@ -99,92 +170,103 @@ export default function Index({ auth, orders, warehouses, filters }) { return ( -

- - 庫存調撥 -

-
- } + breadcrumbs={[ + { label: '商品與庫存與管理', href: '#' }, + { label: '庫存調撥', href: route('inventory.transfer.index'), isPage: true }, + ]} > -
-
-
-
-
-
-
- -
-
- - setSearchQuery(e.target.value)} - /> -
-
+
+
+
+

+ + 庫存調撥管理 +

+

+ 建立與管理倉庫間的商品調撥單,追蹤庫存轉移紀錄。 +

+
+
+ {/* Toolbar */} +
+
+ {/* Search */} +
+ + handleSearchChange(e.target.value)} + className="pl-10 pr-10 h-9" + /> + {searchTerm && ( + + )} +
+ + {/* Warehouse Filter */} + ({ label: w.name, value: w.id.toString() })) + ]} + placeholder="選擇倉庫" + className="w-full md:w-[200px] h-9" + /> + + {/* Action Buttons */} +
+ - - + 建立新調撥單 + + 請選擇來源倉庫與目的倉庫。 + -
+
- + ({ label: w.name, value: w.id.toString() }))} + placeholder="請選擇來源倉庫" + className="h-9" + />
- + w.id.toString() !== sourceWarehouseId).map((w: any) => ({ label: w.name, value: w.id.toString() }))} + placeholder="請選擇目的倉庫" + className="h-9" + />
- +
-
- -
- - - - 單號 - 狀態 - 來源倉庫 - 目的倉庫 - 建立日期 - 過帳日期 - 建立人員 - 操作 - - - - {orders.data.length === 0 ? ( - - - 尚無調撥單 - - - ) : ( - orders.data.map((order) => ( - - {order.doc_no} - {getStatusBadge(order.status)} - {order.from_warehouse_name} - {order.to_warehouse_name} - {order.created_at} - {order.posted_at} - {order.created_by} - - - - - )) - )} - -
-
- -
- -
+
+ +
+ + + + # + 單號 + 狀態 + 來源倉庫 + 目的倉庫 + 建立日期 + 過帳日期 + 建立人員 + 操作 + + + + {orders.data.length === 0 ? ( + + + 尚無調撥紀錄 + + + ) : ( + orders.data.map((order: any, index: number) => ( + router.visit(route('inventory.transfer.show', [order.id]))} + > + + {(orders.current_page - 1) * orders.per_page + index + 1} + + {order.doc_no} + {getStatusBadge(order.status)} + {order.from_warehouse_name} + {order.to_warehouse_name} + {order.created_at} + {order.posted_at || '-'} + {order.created_by} + +
e.stopPropagation()}> + + + + + {order.status === 'draft' && ( + + )} + +
+
+
+ )) + )} +
+
+
+ +
+
+
+ 每頁顯示 + + +
+ 共 {orders.total} 筆紀錄 +
+ +
+ + !open && setDeleteId(null)}> + + + 確定要刪除此調撥單嗎? + + 此動作無法復原。如果單據已存在重要資料,請謹慎操作。 + + + + 取消 + 確認刪除 + + +
); diff --git a/resources/js/Pages/Inventory/Transfer/Show.tsx b/resources/js/Pages/Inventory/Transfer/Show.tsx index 745bf65..7f83559 100644 --- a/resources/js/Pages/Inventory/Transfer/Show.tsx +++ b/resources/js/Pages/Inventory/Transfer/Show.tsx @@ -1,11 +1,10 @@ import { useState, useEffect } from "react"; import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout"; -import { Head, router, usePage, Link } from "@inertiajs/react"; +import { Head, router, Link } from "@inertiajs/react"; import { Button } from "@/Components/ui/button"; import { Input } from "@/Components/ui/input"; import { Label } from "@/Components/ui/label"; -import { Textarea } from "@/Components/ui/textarea"; import { Table, TableBody, @@ -15,38 +14,49 @@ import { TableRow, } from "@/Components/ui/table"; import { Badge } from "@/Components/ui/badge"; +import { Checkbox } from "@/Components/ui/checkbox"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger, - DialogFooter, } from "@/Components/ui/dialog"; import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/Components/ui/select"; -import { Plus, Save, Trash2, ArrowLeft, CheckCircle, Ban, History, Package } from "lucide-react"; + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/Components/ui/alert-dialog"; +import { Plus, Save, Trash2, ArrowLeft, CheckCircle, Package, ArrowLeftRight, Printer, Search } from "lucide-react"; import { toast } from "sonner"; import axios from "axios"; +import { Can } from '@/Components/Permission/Can'; -export default function Show({ auth, order }) { +export default function Show({ order }: any) { const [items, setItems] = useState(order.items || []); const [remarks, setRemarks] = useState(order.remarks || ""); const [isSaving, setIsSaving] = useState(false); + const [deleteId, setDeleteId] = useState(null); + const [isPostDialogOpen, setIsPostDialogOpen] = useState(false); // Product Selection const [isProductDialogOpen, setIsProductDialogOpen] = useState(false); - const [availableInventory, setAvailableInventory] = useState([]); + const [availableInventory, setAvailableInventory] = useState([]); const [loadingInventory, setLoadingInventory] = useState(false); + const [searchQuery, setSearchQuery] = useState(''); + const [selectedInventory, setSelectedInventory] = useState([]); // product_id-batch useEffect(() => { if (isProductDialogOpen) { loadInventory(); + setSelectedInventory([]); + setSearchQuery(''); } }, [isProductDialogOpen]); @@ -64,39 +74,75 @@ export default function Show({ auth, order }) { } }; - const handleAddItem = (inventoryItem) => { - // Check if already added - const exists = items.find(i => - i.product_id === inventoryItem.product_id && - i.batch_number === inventoryItem.batch_number + const toggleSelect = (key: string) => { + setSelectedInventory(prev => + prev.includes(key) ? prev.filter(k => k !== key) : [...prev, key] ); - - if (exists) { - toast.error("該商品與批號已在列表中"); - return; - } - - setItems([...items, { - product_id: inventoryItem.product_id, - product_name: inventoryItem.product_name, - product_code: inventoryItem.product_code, - batch_number: inventoryItem.batch_number, - unit: inventoryItem.unit_name, - quantity: 1, // Default 1 - max_quantity: inventoryItem.quantity, // Max available - notes: "", - }]); - setIsProductDialogOpen(false); }; - const handleUpdateItem = (index, field, value) => { + const toggleSelectAll = () => { + const filtered = availableInventory.filter(inv => + inv.product_name.toLowerCase().includes(searchQuery.toLowerCase()) || + inv.product_code.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)) { + // Check if already added + const exists = newItems.find((i: any) => + i.product_id === inv.product_id && + i.batch_number === inv.batch_number + ); + + if (!exists) { + newItems.push({ + product_id: inv.product_id, + product_name: inv.product_name, + product_code: inv.product_code, + batch_number: inv.batch_number, + unit: inv.unit_name, + quantity: 1, // Default 1 + max_quantity: inv.quantity, // Max available + notes: "", + }); + addedCount++; + } + } + }); + + setItems(newItems); + setIsProductDialogOpen(false); + + if (addedCount > 0) { + toast.success(`已成功加入 ${addedCount} 個項目`); + } else { + toast.info("選取的商品已在清單中"); + } + }; + + const handleUpdateItem = (index: number, field: string, value: any) => { const newItems = [...items]; newItems[index][field] = value; setItems(newItems); }; - const handleRemoveItem = (index) => { - const newItems = items.filter((_, i) => i !== index); + const handleRemoveItem = (index: number) => { + const newItems = items.filter((_: any, i: number) => i !== index); setItems(newItems); }; @@ -116,32 +162,39 @@ export default function Show({ auth, order }) { }; const handlePost = () => { - if (!confirm("確定要過帳嗎?過帳後庫存將立即轉移且無法修改。")) return; router.put(route('inventory.transfer.update', [order.id]), { action: 'post' + }, { + onSuccess: () => { + setIsPostDialogOpen(false); + toast.success("過帳成功"); + } }); }; const handleDelete = () => { - if (!confirm("確定要刪除此草稿嗎?")) return; - router.delete(route('inventory.transfer.destroy', [order.id])); + router.delete(route('inventory.transfer.destroy', [order.id]), { + onSuccess: () => { + setDeleteId(null); + toast.success("已成功刪除"); + } + }); }; const isReadOnly = order.status !== 'draft'; return ( -
-
+
+
+ {!isReadOnly && ( - <> - - - - +
+ + !open && setDeleteId(null)}> + + + + + + 確定要刪除此調撥單嗎? + + 此動作無法復原。如果單據已存在重要資料,請謹慎操作。 + + + + 取消 + 確認刪除 + + + + + + + + + + + + + 確定要過帳嗎? + + 過帳後庫存將立即從「{order.from_warehouse_name}」轉移至「{order.to_warehouse_name}」,且無法再進行修改。 + + + + 取消 + 確認過帳 + + + + +
)}
-
- {/* Header Info */} -
-
- -
{order.from_warehouse_name}
+
+
+ +
+ {isReadOnly ? ( +
+ {order.remarks || '無備註'}
+ ) : ( + setRemarks(e.target.value)} + className="h-9 focus:ring-primary-main" + placeholder="填寫調撥單備註..." + /> + )} +
+ +
+
- -
{order.to_warehouse_name}
-
-
- -
- {order.status === 'draft' && 草稿} - {order.status === 'completed' && 已完成} - {order.status === 'voided' && 已作廢} -
-
-
- - {isReadOnly ? ( -
{order.remarks || '-'}
- ) : ( - setRemarks(e.target.value)} - className="mt-1" - placeholder="填寫備註..." - /> - )} +

調撥明細

+

+ 請選擇要調撥的商品並輸入數量。所有商品將從「{order.from_warehouse_name}」轉出。 +

+ {!isReadOnly && ( + + + + + + + 選擇來源庫存 ({order.from_warehouse_name}) +
+ + setSearchQuery(e.target.value)} + /> +
+
+
+ {loadingInventory ? ( +
+ +

庫存資料載入中...

+
+ ) : ( +
+ + + + + 0 && (() => { + const filtered = availableInventory.filter(inv => + inv.product_name.toLowerCase().includes(searchQuery.toLowerCase()) || + inv.product_code.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()} + /> + + 商品代號 + 品名 + 批號 + 現有庫存 + + + + {(() => { + const filtered = availableInventory.filter(inv => + inv.product_name.toLowerCase().includes(searchQuery.toLowerCase()) || + inv.product_code.toLowerCase().includes(searchQuery.toLowerCase()) + ); + + if (filtered.length === 0) { + return ( + + + {searchQuery ? `找不到與 "${searchQuery}" 相關的商品` : '尚無庫存資料'} + + + ); + } + + return filtered.map((inv) => { + const key = `${inv.product_id}-${inv.batch_number}`; + const isSelected = selectedInventory.includes(key); + return ( + toggleSelect(key)} + > + e.stopPropagation()}> + toggleSelect(key)} + /> + + {inv.product_code} + {inv.product_name} + {inv.batch_number || '-'} + {inv.quantity} {inv.unit_name} + + ); + }); + })()} + +
+
+ )} +
+
+
+
+ 已選取 {selectedInventory.length} 項商品 +
+ {selectedInventory.length > 0 && ( + + )} +
+
+ + +
+
+
+
+ )}
- {/* Items */} -
-
-
-

調撥明細

- {!isReadOnly && ( - - - - - - - 選擇來源庫存 ({order.from_warehouse_name}) - -
- {loadingInventory ? ( -
載入中...
+
+ + + + # + 商品名稱 / 代號 + 批號 + 可用庫存 + 調撥數量 + 單位 + 備註 + {!isReadOnly && } + + + + {items.length === 0 ? ( + + + 尚未加入商品 + + + ) : ( + items.map((item: any, index: number) => ( + + {index + 1} + +
+ {item.product_name} + {item.product_code} +
+
+ {item.batch_number || '-'} + + {item.max_quantity} {item.unit || item.unit_name} + + + {isReadOnly ? ( +
{item.quantity}
) : ( -
- - - 商品代號 - 品名 - 批號 - 現有庫存 - - - - - {availableInventory.map((inv) => ( - - {inv.product_code} - {inv.product_name} - {inv.batch_number || '-'} - {inv.quantity} {inv.unit_name} - - - - - ))} - -
+
+ handleUpdateItem(index, 'quantity', e.target.value)} + className="h-9 w-32 text-right font-medium focus:ring-primary-main" + /> +
)} -
- -
- )} -
- -
- - - - # - 商品 - 批號 - 調撥數量 - 單位 - 備註 - {!isReadOnly && } - - - - {items.length === 0 ? ( - - - 尚未加入商品 + + {item.unit || item.unit_name} + + {isReadOnly ? ( + {item.notes} + ) : ( + handleUpdateItem(index, 'notes', e.target.value)} + placeholder="備註..." + className="h-9 text-sm" + /> + )} + + {!isReadOnly && ( + + - - ) : ( - items.map((item, index) => ( - - {index + 1} - -
{item.product_name}
-
{item.product_code}
-
- {item.batch_number || '-'} - - {isReadOnly ? ( - item.quantity - ) : ( -
- handleUpdateItem(index, 'quantity', e.target.value)} - /> - {item.max_quantity && ( - 上限: {item.max_quantity} - )} -
- )} -
- {item.unit} - - {isReadOnly ? ( - item.notes - ) : ( - handleUpdateItem(index, 'notes', e.target.value)} - placeholder="備註" - /> - )} - - {!isReadOnly && ( - - - - )} -
- )) - )} -
-
-
-
+ )} + + )) + )} + +