Files
star-erp/resources/js/Pages/Inventory/GoodsReceipt/Create.tsx
sky121113 a7c445bd3f
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Successful in 50s
Koori-ERP-Deploy-System / deploy-production (push) Has been skipped
fix: 修正部分進貨採購單更新失敗與狀態顯示問題
2026-01-27 13:27:28 +08:00

638 lines
39 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 AuthenticatedLayout from '@/Layouts/AuthenticatedLayout';
import { Head, useForm } from '@inertiajs/react';
import { Button } from '@/Components/ui/button';
import { Input } from '@/Components/ui/input';
import { Label } from '@/Components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/Components/ui/select';
import { useState } from 'react';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/Components/ui/table';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/Components/ui/alert-dialog";
import {
Search,
Trash2,
Calendar as CalendarIcon,
Save,
ArrowLeft,
Package
} from 'lucide-react';
import axios from 'axios';
interface POItem {
id: number;
product_id: number;
product: { name: string; sku: string };
quantity: number;
received_quantity: number;
unit_price: number;
}
interface PO {
id: number;
code: string;
vendor_id: number;
vendor: { id: number; name: string };
warehouse_id: number | null;
items: POItem[];
}
export default function GoodsReceiptCreate({ warehouses }: { warehouses: any[] }) {
const [poSearch, setPoSearch] = useState('');
const [foundPOs, setFoundPOs] = useState<PO[]>([]);
const [selectedPO, setSelectedPO] = useState<PO | null>(null);
const [isSearching, setIsSearching] = useState(false);
// Manual Selection States
const [vendorSearch, setVendorSearch] = useState('');
const [foundVendors, setFoundVendors] = useState<any[]>([]);
const [selectedVendor, setSelectedVendor] = useState<any | null>(null);
const [productSearch, setProductSearch] = useState('');
const [foundProducts, setFoundProducts] = useState<any[]>([]);
const { data, setData, post, processing, errors } = useForm({
type: 'standard', // 'standard', 'miscellaneous', 'other'
warehouse_id: '',
purchase_order_id: '',
vendor_id: '',
received_date: new Date().toISOString().split('T')[0],
remarks: '',
items: [] as any[],
});
const searchPO = async () => {
if (!poSearch) return;
setIsSearching(true);
try {
const response = await axios.get(route('goods-receipts.search-pos'), {
params: { query: poSearch },
});
setFoundPOs(response.data);
} catch (error) {
console.error('Failed to search POs', error);
} finally {
setIsSearching(false);
}
};
const searchVendors = async () => {
if (!vendorSearch) return;
setIsSearching(true);
try {
const response = await axios.get(route('goods-receipts.search-vendors'), {
params: { query: vendorSearch },
});
setFoundVendors(response.data);
} catch (error) {
console.error('Failed to search vendors', error);
} finally {
setIsSearching(false);
}
};
const searchProducts = async () => {
if (!productSearch) return;
setIsSearching(true);
try {
const response = await axios.get(route('goods-receipts.search-products'), {
params: { query: productSearch },
});
setFoundProducts(response.data);
} catch (error) {
console.error('Failed to search products', error);
} finally {
setIsSearching(false);
}
};
const handleSelectPO = (po: PO) => {
setSelectedPO(po);
setSelectedVendor(po.vendor);
const pendingItems = po.items.map((item) => {
const remaining = item.quantity - item.received_quantity;
return {
product_id: item.product_id,
purchase_order_item_id: item.id,
product_name: item.product.name,
sku: item.product.sku,
quantity_ordered: item.quantity,
quantity_received_so_far: item.received_quantity,
quantity_received: remaining > 0 ? remaining : 0,
unit_price: item.unit_price,
batch_number: '',
expiry_date: '',
};
});
setData((prev) => ({
...prev,
purchase_order_id: po.id.toString(),
vendor_id: po.vendor_id.toString(),
warehouse_id: po.warehouse_id ? po.warehouse_id.toString() : prev.warehouse_id,
items: pendingItems,
}));
setFoundPOs([]);
};
const handleSelectVendor = (vendor: any) => {
setSelectedVendor(vendor);
setData('vendor_id', vendor.id.toString());
setFoundVendors([]);
};
const handleAddProduct = (product: any) => {
const newItem = {
product_id: product.id,
product_name: product.name,
sku: product.code,
quantity_received: 0,
unit_price: product.price || 0,
batch_number: '',
expiry_date: '',
};
setData('items', [...data.items, newItem]);
setFoundProducts([]);
setProductSearch('');
};
const removeItem = (index: number) => {
const newItems = [...data.items];
newItems.splice(index, 1);
setData('items', newItems);
};
const updateItem = (index: number, field: string, value: any) => {
const newItems = [...data.items];
newItems[index] = { ...newItems[index], [field]: value };
setData('items', newItems);
};
const submit = (e: React.FormEvent) => {
e.preventDefault();
post(route('goods-receipts.store'));
};
return (
<AuthenticatedLayout
breadcrumbs={[
{ label: '供應鏈管理', href: '#' },
{ label: '進貨單管理', href: route('goods-receipts.index') },
{ label: '新增進貨單', href: route('goods-receipts.create'), isPage: true },
]}
>
<Head title="新增進貨單" />
<div className="container mx-auto p-6 max-w-7xl">
{/* Header */}
<div className="mb-6">
<Button variant="ghost" asChild className="gap-2 button-outlined-primary mb-4 w-fit">
<ArrowLeft className="h-4 w-4" onClick={() => window.history.back()} />
</Button>
<div className="mb-4">
<h1 className="text-2xl font-bold text-grey-0 flex items-center gap-2">
<Package className="h-6 w-6 text-primary-main" />
</h1>
<p className="text-gray-500 mt-1">
</p>
</div>
</div>
<div className="space-y-6">
{/* Step 0: Select Type */}
<div className="bg-white rounded-lg border shadow-sm p-6">
<Label className="text-sm font-bold mb-3 block"></Label>
<div className="flex gap-4">
{[
{ id: 'standard', label: '標準採購', desc: '從採購單帶入' },
{ id: 'miscellaneous', label: '雜項入庫', desc: '非採購之入庫' },
{ id: 'other', label: '其他', desc: '其他原因入庫' },
].map((t) => (
<button
key={t.id}
onClick={() => {
setData((prev) => ({
...prev,
type: t.id,
purchase_order_id: '',
items: [],
vendor_id: t.id === 'standard' ? prev.vendor_id : '',
}));
setSelectedPO(null);
if (t.id !== 'standard') setSelectedVendor(null);
}}
className={`flex-1 p-4 rounded-xl border-2 text-left transition-all ${data.type === t.id
? 'border-primary-main bg-primary-main/5'
: 'border-gray-100 hover:border-gray-200'
}`}
>
<div className={`font-bold ${data.type === t.id ? 'text-primary-main' : 'text-gray-700'}`}>
{t.label}
</div>
<div className="text-xs text-gray-500">{t.desc}</div>
</button>
))}
</div>
</div>
{/* Step 1: Source Selection */}
<div className="bg-white rounded-lg border shadow-sm overflow-hidden">
<div className="p-6 bg-gray-50/50 border-b flex items-center gap-3">
<div className={`w-8 h-8 rounded-full flex items-center justify-center font-bold ${(data.type === 'standard' ? !!selectedPO : !!selectedVendor)
? 'bg-green-500 text-white' : 'bg-primary text-white'}`}>
{(data.type === 'standard' ? selectedPO : selectedVendor) ? '✓' : '1'}
</div>
<h2 className="text-lg font-bold">
{data.type === 'standard' ? '選擇來源採購單' : '選擇供應商'}
</h2>
</div>
<div className="p-6">
{data.type === 'standard' ? (
!selectedPO ? (
<div className="space-y-4">
<div className="flex gap-4 items-end">
<div className="flex-1 space-y-1">
<Label className="text-xs font-medium text-gray-500"></Label>
<Input
placeholder="輸入採購單號或供應商名稱搜尋..."
value={poSearch}
onChange={(e) => setPoSearch(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && searchPO()}
className="h-9"
/>
</div>
<Button onClick={searchPO} disabled={isSearching} className="button-filled-primary h-9">
<Search className="mr-2 h-4 w-4" />
{isSearching ? '搜尋中...' : '搜尋'}
</Button>
</div>
{foundPOs.length > 0 && (
<div className="bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden">
<Table>
<TableHeader className="bg-gray-50">
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="w-[100px] text-center"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{foundPOs.map((po) => (
<TableRow key={po.id}>
<TableCell className="font-medium text-primary-main">{po.code}</TableCell>
<TableCell>{po.vendor?.name}</TableCell>
<TableCell className="text-center">
<Button size="sm" onClick={() => handleSelectPO(po)} className="button-outlined-primary">
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</div>
) : (
<div className="flex items-center justify-between bg-primary-main/5 p-4 rounded-xl border border-primary-main/20">
<div className="flex gap-8">
<div>
<span className="text-xs text-gray-500 block"></span>
<span className="font-bold text-primary-main">{selectedPO.code}</span>
</div>
<div>
<span className="text-xs text-gray-500 block"></span>
<span className="font-bold text-gray-800">{selectedPO.vendor?.name}</span>
</div>
</div>
<Button variant="ghost" size="sm" onClick={() => setSelectedPO(null)} className="text-gray-500 hover:text-red-500">
</Button>
</div>
)
) : (
!selectedVendor ? (
<div className="space-y-4">
<div className="flex gap-4 items-end">
<div className="flex-1 space-y-1">
<Label className="text-xs font-medium text-gray-500"></Label>
<Input
placeholder="輸入供應商名稱或代號搜尋..."
value={vendorSearch}
onChange={(e) => setVendorSearch(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && searchVendors()}
className="h-9"
/>
</div>
<Button onClick={searchVendors} disabled={isSearching} className="button-filled-primary h-9">
<Search className="mr-2 h-4 w-4" />
{isSearching ? '搜尋中...' : '搜尋'}
</Button>
</div>
{foundVendors.length > 0 && (
<div className="bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden">
<Table>
<TableHeader className="bg-gray-50">
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="w-[100px] text-center"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{foundVendors.map((v) => (
<TableRow key={v.id}>
<TableCell className="font-medium">{v.name}</TableCell>
<TableCell>{v.code}</TableCell>
<TableCell className="text-center">
<Button size="sm" onClick={() => handleSelectVendor(v)} className="button-outlined-primary">
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</div>
) : (
<div className="flex items-center justify-between bg-primary-main/5 p-4 rounded-xl border border-primary-main/20">
<div className="flex gap-8">
<div>
<span className="text-xs text-gray-500 block"></span>
<span className="font-bold text-primary-main">{selectedVendor.name}</span>
</div>
<div>
<span className="text-xs text-gray-500 block"></span>
<span className="font-bold text-gray-800">{selectedVendor.code}</span>
</div>
</div>
<Button variant="ghost" size="sm" onClick={() => setSelectedVendor(null)} className="text-gray-500 hover:text-red-500">
</Button>
</div>
)
)}
</div>
</div>
{/* Step 2: Details & Items */}
{((data.type === 'standard' && selectedPO) || (data.type !== 'standard' && selectedVendor)) && (
<div className="bg-white rounded-lg border shadow-sm overflow-hidden animate-in fade-in slide-in-from-bottom-4 duration-500">
<div className="p-6 bg-gray-50/50 border-b flex items-center gap-3">
<div className="w-8 h-8 rounded-full bg-primary text-white flex items-center justify-center font-bold">2</div>
<h2 className="text-lg font-bold"></h2>
</div>
<div className="p-6 space-y-8">
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="space-y-2">
<Label htmlFor="warehouse_id"> <span className="text-red-500">*</span></Label>
<Select
value={data.warehouse_id}
onValueChange={(val) => setData('warehouse_id', val)}
>
<SelectTrigger className="h-9">
<SelectValue placeholder="選擇倉庫" />
</SelectTrigger>
<SelectContent>
{warehouses.map(w => (
<SelectItem key={w.id} value={w.id.toString()}>{w.name} ({w.type})</SelectItem>
))}
</SelectContent>
</Select>
{errors.warehouse_id && <p className="text-red-500 text-xs">{errors.warehouse_id}</p>}
</div>
<div className="space-y-2">
<Label htmlFor="received_date"> <span className="text-red-500">*</span></Label>
<div className="relative">
<CalendarIcon className="absolute left-2.5 top-2.5 h-4 w-4 text-gray-400 pointer-events-none" />
<Input
type="date"
value={data.received_date}
onChange={(e) => setData('received_date', e.target.value)}
className="pl-9 h-9 block w-full"
/>
</div>
{errors.received_date && <p className="text-red-500 text-xs">{errors.received_date}</p>}
</div>
<div className="space-y-2">
<Label htmlFor="remarks"></Label>
<Input
value={data.remarks}
onChange={(e) => setData('remarks', e.target.value)}
className="h-9"
placeholder="選填..."
/>
</div>
</div>
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="font-bold text-gray-700"></h3>
{data.type !== 'standard' && (
<div className="flex gap-2 items-center">
<div className="relative">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-gray-400" />
<Input
placeholder="搜尋商品加入..."
value={productSearch}
onChange={(e) => setProductSearch(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && searchProducts()}
className="h-9 w-64 pl-9"
/>
{foundProducts.length > 0 && (
<div className="absolute top-10 left-0 w-full bg-white border rounded-lg shadow-xl z-50 max-h-60 overflow-y-auto">
{foundProducts.map(p => (
<button
key={p.id}
onClick={() => handleAddProduct(p)}
className="w-full text-left p-3 hover:bg-gray-50 border-b last:border-0 flex flex-col"
>
<span className="font-bold text-sm">{p.name}</span>
<span className="text-xs text-gray-500">{p.code}</span>
</button>
))}
</div>
)}
</div>
<Button onClick={searchProducts} disabled={isSearching} size="sm" className="button-filled-primary h-9">
</Button>
</div>
)}
</div>
<div className="bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden">
<Table>
<TableHeader className="bg-gray-50">
<TableRow>
<TableHead className="w-[200px]"></TableHead>
<TableHead className="w-[120px] text-center">
{data.type === 'standard' ? '採購量 / 已收' : '規格'}
</TableHead>
<TableHead className="w-[100px] text-right"></TableHead>
<TableHead className="w-[100px]"> <span className="text-red-500">*</span></TableHead>
<TableHead className="w-[120px]"></TableHead>
<TableHead className="w-[120px]"></TableHead>
<TableHead className="w-[80px] text-right"></TableHead>
{data.type !== 'standard' && <TableHead className="w-[50px]"></TableHead>}
</TableRow>
</TableHeader>
<TableBody>
{data.items.length === 0 ? (
<TableRow>
<TableCell colSpan={data.type === 'standard' ? 7 : 8} className="text-center py-8 text-gray-400 italic">
</TableCell>
</TableRow>
) : (
data.items.map((item, index) => {
const errorKey = `items.${index}.quantity_received` as keyof typeof errors;
return (
<TableRow key={index} className="hover:bg-gray-50/50 text-sm">
<TableCell>
<div className="font-medium text-gray-900">{item.product_name}</div>
<div className="text-xs text-gray-500">{item.sku}</div>
</TableCell>
<TableCell className="text-center text-gray-600">
{data.type === 'standard'
? `${item.quantity_ordered} / ${item.quantity_received_so_far}`
: '一般'}
</TableCell>
<TableCell className="text-right">
<Input
type="number"
step="0.01"
value={item.unit_price}
onChange={(e) => updateItem(index, 'unit_price', e.target.value)}
className="h-8 text-right w-20 ml-auto"
disabled={data.type === 'standard'}
/>
</TableCell>
<TableCell>
<Input
type="number"
min="0"
step="0.01"
value={item.quantity_received}
onChange={(e) => updateItem(index, 'quantity_received', e.target.value)}
className={`h-8 w-20 ${errors[errorKey] ? 'border-red-500' : ''}`}
/>
{errors[errorKey] && (
<p className="text-red-500 text-[10px] mt-1">{errors[errorKey] as string}</p>
)}
</TableCell>
<TableCell>
<Input
value={item.batch_number}
onChange={(e) => updateItem(index, 'batch_number', e.target.value)}
placeholder="選填"
className="h-8"
/>
</TableCell>
<TableCell>
<Input
type="date"
value={item.expiry_date}
onChange={(e) => updateItem(index, 'expiry_date', e.target.value)}
className="h-8"
/>
</TableCell>
<TableCell className="text-right font-medium">
${(parseFloat(item.quantity_received || 0) * parseFloat(item.unit_price)).toLocaleString()}
</TableCell>
{data.type !== 'standard' && (
<TableCell className="text-center">
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="outline"
size="sm"
className="button-outlined-error"
title="移除項目"
>
<Trash2 className="h-4 w-4" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle>
<AlertDialogDescription>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction
onClick={() => removeItem(index)}
className="bg-red-600 hover:bg-red-700"
>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</TableCell>
)}
</TableRow>
)
})
)}
</TableBody>
</Table>
</div>
</div>
</div>
</div>
)}
</div>
{/* Bottom Action Bar */}
<div className="flex items-center justify-end gap-4 py-6 border-t border-gray-100 mt-6">
<Button variant="ghost" className="h-11 px-6 text-gray-500 hover:text-gray-700" onClick={() => window.history.back()}>
</Button>
<Button
size="lg"
className="button-filled-primary px-12 h-14 rounded-xl shadow-lg text-lg font-bold transition-all hover:scale-[1.02] active:scale-[0.98]"
onClick={submit}
disabled={processing || (data.type === 'standard' ? !selectedPO : !selectedVendor)}
>
<Save className="mr-2 h-5 w-5" />
{processing ? '處理中...' : '確認進貨'}
</Button>
</div>
</div>
</AuthenticatedLayout>
);
}