Files
star-erp/resources/js/Pages/Inventory/Transfer/Show.tsx

692 lines
38 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useEffect, useMemo } from "react";
import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout";
import { Head, router, Link, usePage } from "@inertiajs/react";
import { Button } from "@/Components/ui/button";
import { Input } from "@/Components/ui/input";
import { Label } from "@/Components/ui/label";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/Components/ui/table";
import { StatusBadge } from "@/Components/shared/StatusBadge";
import { SearchableSelect } from "@/Components/ui/searchable-select";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/Components/ui/alert-dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/Components/ui/select";
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';
import { usePermission } from '@/hooks/usePermission';
import TransferImportDialog from '@/Components/Transfer/TransferImportDialog';
interface TransitWarehouse {
id: string;
name: string;
license_plate: string | null;
driver_name: string | null;
}
export default function Show({ order, transitWarehouses = [] }: { order: any; transitWarehouses?: TransitWarehouse[] }) {
const { can } = usePermission();
const { url } = usePage();
// 解析 URL query 參數,判斷使用者從哪裡來
const backNav = useMemo(() => {
const params = new URLSearchParams(url.split('?')[1] || '');
const from = params.get('from');
if (from === 'requisition') {
const fromId = params.get('from_id');
const fromDoc = params.get('from_doc') || '';
return {
href: route('store-requisitions.show', [fromId!]),
label: `返回叫貨單: ${decodeURIComponent(fromDoc)}`,
breadcrumbs: [
{ label: '商品與庫存管理', href: '#' },
{ label: '門市叫貨申請', href: route('store-requisitions.index') },
{ label: `叫貨單: ${decodeURIComponent(fromDoc)}`, href: route('store-requisitions.show', [fromId!]) },
{ label: `調撥單: ${order.doc_no}`, href: route('inventory.transfer.show', [order.id]), isPage: true },
],
};
}
return {
href: route('inventory.transfer.index'),
label: '返回調撥單列表',
breadcrumbs: [
{ label: '商品與庫存管理', href: '#' },
{ label: '庫存調撥', href: route('inventory.transfer.index') },
{ label: `調撥單: ${order.doc_no}`, href: route('inventory.transfer.show', [order.id]), isPage: true },
],
};
}, [url, order]);
const [items, setItems] = useState(order.items || []);
const [remarks, setRemarks] = useState(order.remarks || "");
// 狀態初始化
const [transitWarehouseId, setTransitWarehouseId] = useState<string | null>(order.transit_warehouse_id || null);
const [isSaving, setIsSaving] = useState(false);
const [deleteId, setDeleteId] = useState<string | null>(null);
const [isPostDialogOpen, setIsPostDialogOpen] = useState(false);
const [isReceiveDialogOpen, setIsReceiveDialogOpen] = useState(false);
const [isImportDialogOpen, setIsImportDialogOpen] = useState(false);
// 判斷是否有在途倉流程 (包含前端暫選的)
const hasTransit = !!transitWarehouseId;
// 取得選中的在途倉資訊
const selectedTransitWarehouse = transitWarehouses.find(w => w.id === transitWarehouseId);
// 當 order prop 變動時 (例如匯入後 router.reload),同步更新內部狀態
useEffect(() => {
if (order) {
setItems(order.items || []);
setRemarks(order.remarks || "");
setTransitWarehouseId(order.transit_warehouse_id || null);
}
}, [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 [availableInventory, setAvailableInventory] = useState<any[]>([]);
const [loadingInventory, setLoadingInventory] = useState(false);
useEffect(() => {
if (!isItemsReadOnly && order.from_warehouse_id) {
loadInventory();
}
}, [isItemsReadOnly, order.from_warehouse_id]);
const loadInventory = async () => {
setLoadingInventory(true);
try {
const response = await axios.get(route('api.warehouses.inventories', order.from_warehouse_id));
setAvailableInventory(response.data);
} catch (error) {
console.error("Failed to load inventory", error);
toast.error("無法載入庫存資料");
} finally {
setLoadingInventory(false);
}
};
const handleAddItem = () => {
setItems([
...items,
{
product_id: "",
product_name: "",
product_code: "",
batch_number: "",
expiry_date: null,
unit: "",
quantity: "",
max_quantity: 0,
position: "",
notes: ""
}
]);
};
const handleUpdateItem = (index: number, field: string, value: any) => {
const newItems = [...items];
newItems[index][field] = value;
setItems(newItems);
};
const handleRemoveItem = (index: number) => {
const newItems = items.filter((_: any, i: number) => i !== index);
setItems(newItems);
};
const handleSave = async () => {
setIsSaving(true);
try {
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("儲存失敗,請檢查輸入"),
});
} finally {
setIsSaving(false);
}
};
// 確認出貨 / 確認過帳(無在途倉)
const handlePost = () => {
const payload: any = {
action: 'post',
transit_warehouse_id: transitWarehouseId || '',
remarks: remarks,
};
if (!order.requisition) {
payload.items = items;
}
router.put(route('inventory.transfer.update', [order.id]), payload, {
onSuccess: () => {
setIsPostDialogOpen(false);
},
onError: (errors) => {
const message = Object.values(errors).join('\n') || "操作失敗,請檢查輸入或庫存狀態";
toast.error(message);
setIsPostDialogOpen(false);
}
});
};
// 確認收貨
const handleReceive = () => {
router.put(route('inventory.transfer.update', [order.id]), {
action: 'receive'
}, {
onSuccess: () => {
setIsReceiveDialogOpen(false);
},
onError: (errors) => {
const message = Object.values(errors).join('\n') || "收貨失敗";
toast.error(message);
setIsReceiveDialogOpen(false);
}
});
};
const handleDelete = () => {
router.delete(route('inventory.transfer.destroy', [order.id]), {
onSuccess: () => {
setDeleteId(null);
}
});
};
// 狀態 Badge 渲染
const renderStatusBadge = () => {
const statusConfig: Record<string, { variant: "success" | "warning" | "neutral" | "destructive" | "info", label: string }> = {
completed: { variant: 'success', label: '已完成' },
dispatched: { variant: 'warning', label: '配送中' },
draft: { variant: 'neutral', label: '草稿' },
voided: { variant: 'destructive', label: '已作廢' },
};
const config = statusConfig[order.status] || { variant: 'neutral', label: order.status };
return <StatusBadge variant={config.variant}>{config.label}</StatusBadge>;
};
// 過帳時庫存欄標題
const stockColumnTitle = () => {
if (order.status === 'completed' || order.status === 'dispatched') return '出貨時庫存';
return '可用庫存';
};
return (
<AuthenticatedLayout
breadcrumbs={backNav.breadcrumbs as any}
>
<Head title={`調撥單 ${order.doc_no}`} />
<div className="container mx-auto p-6 max-w-7xl animate-in fade-in duration-500 space-y-6">
<div>
<Link href={backNav.href}>
<Button
variant="outline"
className="gap-2 button-outlined-primary mb-6"
>
<ArrowLeft className="h-4 w-4" />
{backNav.label}
</Button>
</Link>
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
<div>
<div className="flex items-center gap-2">
<h1 className="text-2xl font-bold text-grey-0 flex items-center gap-2">
<ArrowLeftRight className="h-6 w-6 text-primary-main" />
調: {order.doc_no}
</h1>
{renderStatusBadge()}
</div>
<p className="text-sm text-gray-500 mt-1 font-medium">
: {order.from_warehouse_name} <ArrowLeftRight className="inline-block h-3 w-3 mx-1" /> : {order.to_warehouse_name} <span className="mx-2">|</span> : {order.created_by}
</p>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
className="button-outlined-primary"
onClick={() => window.print()}
>
<Printer className="w-4 h-4 mr-2" />
</Button>
{/* 草稿狀態:儲存 + 出貨/過帳 + 刪除 */}
{!isReadOnly && (
<div className="flex items-center gap-2">
<Can permission="inventory_transfer.delete">
<AlertDialog open={!!deleteId} onOpenChange={(open) => !open && setDeleteId(null)}>
<AlertDialogTrigger asChild>
<Button variant="outline" size="sm" className="button-outlined-error" onClick={() => setDeleteId(order.id)}>
<Trash2 className="w-4 h-4 mr-2" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>調</AlertDialogTitle>
<AlertDialogDescription>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction onClick={handleDelete} className="button-filled-error"></AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</Can>
<Can permission="inventory_transfer.edit">
<Button
variant="outline"
size="sm"
className="button-outlined-primary"
onClick={handleSave}
disabled={isSaving}
>
<Save className="w-4 h-4 mr-2" />
稿
</Button>
<AlertDialog open={isPostDialogOpen} onOpenChange={setIsPostDialogOpen}>
<AlertDialogTrigger asChild>
<Button
size="sm"
className="button-filled-primary"
disabled={items.length === 0 || isSaving}
>
{hasTransit ? (
<><Truck className="w-4 h-4 mr-2" /></>
) : (
<><CheckCircle className="w-4 h-4 mr-2" /></>
)}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{hasTransit ? '確定要出貨嗎?' : '確定要過帳嗎?'}
</AlertDialogTitle>
<AlertDialogDescription>
{hasTransit ? (
<>{order.from_warehouse_name}{selectedTransitWarehouse?.name || order.transit_warehouse_name}調</>
) : (
<>{order.from_warehouse_name}{order.to_warehouse_name}</>
)}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction onClick={handlePost} className="button-filled-primary">
{hasTransit ? '確認出貨' : '確認過帳'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</Can>
</div>
)}
{/* 已出貨狀態:確認收貨按鈕 */}
{order.status === 'dispatched' && (
<Can permission="inventory_transfer.edit">
<AlertDialog open={isReceiveDialogOpen} onOpenChange={setIsReceiveDialogOpen}>
<AlertDialogTrigger asChild>
<Button
size="sm"
className="bg-green-600 hover:bg-green-700 text-white"
>
<PackageCheck className="w-4 h-4 mr-2" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle>
<AlertDialogDescription>
{order.transit_warehouse_name}{order.to_warehouse_name}調
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction onClick={handleReceive} className="bg-green-600 hover:bg-green-700 text-white"></AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</Can>
)}
</div>
</div>
</div>
{/* 在途倉資訊卡片 */}
{(hasTransit || transitWarehouses.length > 0) && (
<div className="bg-white rounded-lg shadow-sm border p-6 space-y-4">
<div className="flex items-center gap-2">
<Truck className="h-5 w-5 text-orange-500" />
<Label className="text-gray-700 font-semibold text-base"></Label>
</div>
{order.status === 'draft' && canEdit ? (
/* 草稿狀態:可選擇在途倉 */
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<Label className="text-xs text-gray-500 mb-1 block"></Label>
<Select
value={transitWarehouseId || ''}
onValueChange={(v) => setTransitWarehouseId(v === 'none' ? null : v)}
>
<SelectTrigger className="h-9">
<SelectValue placeholder="不使用在途倉(直接過帳)" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">使</SelectItem>
{transitWarehouses.map((w) => (
<SelectItem key={w.id} value={w.id}>
{w.name} {w.license_plate ? `(${w.license_plate})` : ''}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{selectedTransitWarehouse && (
<>
<div>
<Label className="text-xs text-gray-500 mb-1 block"></Label>
<div className="text-sm font-medium text-gray-700 p-2 bg-gray-50 rounded border">
{selectedTransitWarehouse.license_plate || '-'}
</div>
</div>
<div>
<Label className="text-xs text-gray-500 mb-1 block"></Label>
<div className="text-sm font-medium text-gray-700 p-2 bg-gray-50 rounded border">
{selectedTransitWarehouse.driver_name || '-'}
</div>
</div>
</>
)}
</div>
) : hasTransit ? (
/* 非草稿狀態:唯讀顯示在途倉資訊 */
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div>
<Label className="text-xs text-gray-500 mb-1 block"></Label>
<div className="text-sm font-semibold text-gray-700">{order.transit_warehouse_name}</div>
</div>
<div>
<Label className="text-xs text-gray-500 mb-1 block"></Label>
<div className="text-sm font-medium text-gray-700">{order.transit_warehouse_plate || '-'}</div>
</div>
<div>
<Label className="text-xs text-gray-500 mb-1 block"></Label>
<div className="text-sm font-medium text-gray-700">{order.transit_warehouse_driver || '-'}</div>
</div>
<div>
<Label className="text-xs text-gray-500 mb-1 block"></Label>
<div className="text-sm font-medium">
{order.status === 'dispatched' && (
<span className="text-orange-600">{order.dispatched_at}</span>
)}
{order.status === 'completed' && (
<span className="text-green-600">{order.received_at}</span>
)}
</div>
</div>
</div>
) : null}
{/* 顯示時間軸(已出貨或已完成時) */}
{(order.dispatched_at || order.received_at) && (
<div className="border-t pt-3 mt-3 flex flex-wrap gap-6 text-sm text-gray-500">
{order.dispatched_at && (
<div className="flex items-center gap-1.5">
<Truck className="h-3.5 w-3.5 text-orange-400" />
<span>{order.dispatched_at}</span>
<span className="text-gray-400">({order.dispatched_by})</span>
</div>
)}
{order.received_at && (
<div className="flex items-center gap-1.5">
<PackageCheck className="h-3.5 w-3.5 text-green-500" />
<span>{order.received_at}</span>
<span className="text-gray-400">({order.received_by})</span>
</div>
)}
</div>
)}
</div>
)}
<div className="bg-white rounded-lg shadow-sm border p-6 space-y-4">
<div className="flex items-center justify-between">
<Label className="text-gray-500 font-semibold"></Label>
</div>
{isReadOnly ? (
<div className="text-gray-700 p-2 bg-gray-50 rounded border text-sm min-h-[40px]">
{order.remarks || '無備註'}
</div>
) : (
<Input
value={remarks || ""}
onChange={(e) => setRemarks(e.target.value)}
className="h-9 focus:ring-primary-main"
placeholder="填寫調撥單備註..."
/>
)}
</div>
<div className="bg-white rounded-lg shadow-sm border p-6 space-y-4">
<div className="flex items-center justify-between">
<div>
<h3 className="font-semibold text-lg text-grey-900">調</h3>
<p className="text-sm text-grey-500">
調{order.from_warehouse_name}
</p>
</div>
{!isItemsReadOnly && (
<div className="flex gap-2">
<Button variant="outline" className="button-outlined-primary" onClick={() => setIsImportDialogOpen(true)}>
<Package className="h-4 w-4 mr-2" />
Excel
</Button>
<TransferImportDialog
open={isImportDialogOpen}
onOpenChange={setIsImportDialogOpen}
orderId={order.id}
/>
<Button variant="outline" className="button-outlined-primary" onClick={handleAddItem}>
<Plus className="h-4 w-4 mr-2" />
</Button>
</div>
)}
</div>
<div className="border rounded-lg overflow-hidden">
<Table>
<TableHeader className="bg-gray-50">
<TableRow>
<TableHead className="w-[50px] text-center 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 w-32 font-medium text-grey-600">
{stockColumnTitle()}
</TableHead>
<TableHead className="text-right w-40 font-medium text-grey-600">調</TableHead>
<TableHead className="font-medium text-grey-600"></TableHead>
{isVending && <TableHead className="font-medium text-grey-600"></TableHead>}
<TableHead className="font-medium text-grey-600"></TableHead>
{!isItemsReadOnly && <TableHead className="w-[50px]"></TableHead>}
</TableRow>
</TableHeader>
<TableBody>
{items.length === 0 ? (
<TableRow>
<TableCell colSpan={isVending ? 9 : 8} className="text-center h-24 text-gray-500">
</TableCell>
</TableRow>
) : (
items.map((item: any, index: number) => (
<TableRow key={index}>
<TableCell className="text-center text-gray-500 font-medium">{index + 1}</TableCell>
<TableCell className="py-3">
{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>
{item.expiry_date && (
<div className="text-xs text-gray-400 mt-1">
: {item.expiry_date}
</div>
)}
</TableCell>
<TableCell className="text-right font-semibold text-primary-main">
{item.product_id ? `${item.max_quantity} ${item.unit || item.unit_name || ''}` : '-'}
</TableCell>
<TableCell className="px-1 py-3">
{isItemsReadOnly ? (
<div className="text-right font-semibold mr-2">{item.quantity}</div>
) : (
<div className="flex flex-col gap-1 items-end pr-2">
<Input
type="number"
min="0.01"
step="any"
value={item.quantity ?? ""}
onChange={(e) => handleUpdateItem(index, 'quantity', e.target.value)}
className="h-9 w-32 font-medium focus:ring-primary-main text-right"
/>
</div>
)}
</TableCell>
<TableCell className="text-sm text-gray-500">{item.unit || item.unit_name}</TableCell>
{isVending && (
<TableCell className="px-1">
{isItemsReadOnly ? (
<span className="text-sm font-medium">{item.position}</span>
) : (
<Input
value={item.position || ""}
onChange={(e) => handleUpdateItem(index, 'position', e.target.value)}
placeholder="貨道..."
className="h-9 w-24 text-sm font-medium"
/>
)}
</TableCell>
)}
<TableCell className="px-1">
{isItemsReadOnly ? (
<span className="text-sm text-gray-600">{item.notes}</span>
) : (
<Input
value={item.notes || ""}
onChange={(e) => handleUpdateItem(index, 'notes', e.target.value)}
placeholder="備註..."
className="h-9 text-sm"
/>
)}
</TableCell>
{!isItemsReadOnly && (
<TableCell className="text-center">
<Button variant="outline" size="icon" className="button-outlined-error h-8 w-8" onClick={() => handleRemoveItem(index)}>
<Trash2 className="h-4 w-4" />
</Button>
</TableCell>
)}
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</div>
</div>
</AuthenticatedLayout>
);
}