Files
star-erp/resources/js/Pages/Inventory/GoodsReceipt/Create.tsx
sky121113 95d8dc2e84
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Successful in 51s
Koori-ERP-Deploy-System / deploy-production (push) Has been skipped
feat: 統一進貨單 UI、修復庫存異動紀錄與廠商詳情顯示報錯
2026-01-27 17:23:31 +08:00

764 lines
45 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, Link } 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 { SearchableSelect } from '@/Components/ui/searchable-select';
import React, { useState, useEffect } from 'react';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/Components/ui/table';
import { Badge } from "@/Components/ui/badge";
import {
Search,
Trash2,
Calendar as CalendarIcon,
Save,
ArrowLeft,
Package
} from 'lucide-react';
import axios from 'axios';
import { PurchaseOrderStatus } from '@/types/purchase-order';
import { STATUS_CONFIG } from '@/constants/purchase-order';
interface BatchItem {
inventoryId: string;
batchNumber: string;
originCountry: string;
expiryDate: string | null;
quantity: number;
}
// 待進貨採購單 Item 介面
interface PendingPOItem {
id: number;
product_id: number;
product_name: string;
product_code: string;
unit: string;
quantity: number;
received_quantity: number;
remaining: number;
unit_price: number;
batchMode?: 'existing' | 'new';
originCountry?: string; // For new batch generation
}
// 待進貨採購單介面
interface PendingPO {
id: number;
code: string;
status: PurchaseOrderStatus;
vendor_id: number;
vendor_name: string;
warehouse_id: number | null;
order_date: string;
items: PendingPOItem[];
}
// 廠商介面
interface Vendor {
id: number;
name: string;
code: string;
}
interface Props {
warehouses: { id: number; name: string; type: string }[];
pendingPurchaseOrders: PendingPO[];
vendors: Vendor[];
}
export default function GoodsReceiptCreate({ warehouses, pendingPurchaseOrders, vendors }: Props) {
const [selectedPO, setSelectedPO] = useState<PendingPO | null>(null);
const [selectedVendor, setSelectedVendor] = useState<Vendor | null>(null);
const [isSearching, setIsSearching] = useState(false);
// Manual Product Search States
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[],
});
// 搜尋商品 API用於雜項入庫/其他類型)
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: PendingPO) => {
setSelectedPO(po);
// 將採購單項目轉換為進貨單項目,預填剩餘可收貨量
const pendingItems = po.items.map((item) => ({
product_id: item.product_id,
purchase_order_item_id: item.id,
product_name: item.product_name,
sku: item.product_code,
unit: item.unit,
quantity_ordered: item.quantity,
quantity_received_so_far: item.received_quantity,
quantity_received: item.remaining, // 預填剩餘量
unit_price: item.unit_price,
batch_number: '',
batchMode: 'new',
originCountry: 'TW',
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,
}));
};
// 選擇廠商(雜項入庫/其他)
const handleSelectVendor = (vendorId: string) => {
const vendor = vendors.find(v => v.id.toString() === vendorId);
if (vendor) {
setSelectedVendor(vendor);
setData('vendor_id', vendor.id.toString());
}
};
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: '',
batchMode: 'new',
originCountry: 'TW',
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);
};
// Generate batch preview (Added)
const getBatchPreview = (productId: number, productCode: string, country: string, dateStr: string) => {
if (!productCode || !productId) return "--";
try {
const datePart = dateStr.includes('T') ? dateStr.split('T')[0] : dateStr;
const [yyyy, mm, dd] = datePart.split('-');
const dateFormatted = `${yyyy}${mm}${dd}`;
const seqKey = `${productId}-${country}-${datePart}`;
// Handle sequence. Note: nextSequences values are numbers.
const seq = nextSequences[seqKey]?.toString().padStart(2, '0') || "01";
return `${productCode}-${country}-${dateFormatted}-${seq}`;
} catch (e) {
return "--";
}
};
// Batch management
const [batchesCache, setBatchesCache] = useState<Record<string, BatchItem[]>>({});
const [nextSequences, setNextSequences] = useState<Record<string, number>>({});
// Fetch batches and sequence for a product
const fetchProductBatches = async (productId: number, country: string = 'TW', dateStr: string = '') => {
if (!data.warehouse_id) return;
const cacheKey = `${productId}-${data.warehouse_id}`;
try {
const today = new Date().toISOString().split('T')[0];
const targetDate = dateStr || data.received_date || today;
// Adjust API endpoint to match AddInventory logic
// Assuming GoodsReceiptController or existing WarehouseController can handle this.
// Using the same endpoint as AddInventory: /api/warehouses/{id}/inventory/batches/{productId}
const response = await axios.get(
`/api/warehouses/${data.warehouse_id}/inventory/batches/${productId}`,
{
params: {
origin_country: country,
arrivalDate: targetDate
}
}
);
if (response.data) {
// Update existing batches list
if (response.data.batches) {
setBatchesCache(prev => ({
...prev,
[cacheKey]: response.data.batches
}));
}
// Update next sequence for new batch generation
if (response.data.nextSequence !== undefined) {
const seqKey = `${productId}-${country}-${targetDate}`;
setNextSequences(prev => ({
...prev,
[seqKey]: parseInt(response.data.nextSequence)
}));
}
}
} catch (error) {
console.error("Failed to fetch batches", error);
}
};
// Trigger batch fetch when relevant fields change
useEffect(() => {
data.items.forEach(item => {
if (item.product_id && data.warehouse_id) {
const country = item.originCountry || 'TW';
const date = data.received_date;
fetchProductBatches(item.product_id, country, date);
}
});
}, [data.items.length, data.warehouse_id, data.received_date, JSON.stringify(data.items.map(i => i.originCountry))]);
useEffect(() => {
data.items.forEach((item, index) => {
if (item.batchMode === 'new' && item.originCountry && data.received_date) {
const country = item.originCountry;
// Use date from form or today
const dateStr = data.received_date || new Date().toISOString().split('T')[0];
const seqKey = `${item.product_id}-${country}-${dateStr}`;
const seq = nextSequences[seqKey]?.toString().padStart(3, '0') || '001';
// Only generate if we have a sequence (or default)
// Note: fetch might not have returned yet, so seq might be default 001 until fetch updates nextSequences
const datePart = dateStr.replace(/-/g, '');
const generatedBatch = `${item.sku}-${country}-${datePart}-${seq}`;
if (item.batch_number !== generatedBatch) {
// Update WITHOUT triggering re-render loop
// Need a way to update item silently or check condition carefully
// Using setBatchNumber might trigger this effect again but value will be same.
const newItems = [...data.items];
newItems[index].batch_number = generatedBatch;
setData('items', newItems);
}
}
});
}, [nextSequences, JSON.stringify(data.items.map(i => ({ m: i.batchMode, c: i.originCountry, s: i.sku, p: i.product_id }))), data.received_date]);
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">
<Link href={route('goods-receipts.index')}>
<Button variant="outline" className="gap-2 mb-4 w-fit">
<ArrowLeft className="h-4 w-4" />
</Button>
</Link>
<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 text-sm ${(data.type === 'standard' ? !!selectedPO : !!selectedVendor)
? 'bg-green-500 text-white shadow-sm' : 'bg-primary-main text-white shadow-sm'}`}>
{(data.type === 'standard' ? selectedPO : selectedVendor) ? '✓' : '1'}
</div>
<h2 className="text-lg font-bold text-gray-800">
{data.type === 'standard' ? '選擇來源採購單' : '選擇供應商'}
</h2>
</div>
<div className="p-6">
{data.type === 'standard' ? (
!selectedPO ? (
<div className="space-y-4">
<Label className="text-sm font-medium text-gray-700"></Label>
{pendingPurchaseOrders.length === 0 ? (
<div className="text-center py-8 text-gray-500 bg-gray-50 rounded-lg border border-dashed">
</div>
) : (
<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="text-center"></TableHead>
<TableHead className="text-center"></TableHead>
<TableHead className="w-[100px] text-center"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{pendingPurchaseOrders.map((po) => (
<TableRow key={po.id} className="hover:bg-gray-50/50">
<TableCell className="font-medium text-primary-main">{po.code}</TableCell>
<TableCell>{po.vendor_name}</TableCell>
<TableCell className="text-center">
<Badge variant={STATUS_CONFIG[po.status]?.variant || 'outline'}>
{STATUS_CONFIG[po.status]?.label || po.status}
</Badge>
</TableCell>
<TableCell className="text-center text-gray-600">
{po.items.length}
</TableCell>
<TableCell className="text-center">
<Button size="sm" onClick={() => handleSelectPO(po)} className="button-filled-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>
<span className="text-xs text-gray-500 block"></span>
<span className="font-bold text-gray-800">{selectedPO.items.length} </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="space-y-2">
<Label className="text-sm font-medium text-gray-700"></Label>
<SearchableSelect
value=""
onValueChange={handleSelectVendor}
options={vendors.map(v => ({
label: `${v.name} (${v.code})`,
value: v.id.toString()
}))}
placeholder="選擇供應商..."
searchPlaceholder="搜尋供應商..."
className="h-9 w-full max-w-md"
/>
</div>
{vendors.length === 0 && (
<div className="text-center py-8 text-gray-500 bg-gray-50 rounded-lg border border-dashed">
</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-main text-white flex items-center justify-center font-bold text-sm shadow-sm">2</div>
<h2 className="text-lg font-bold text-gray-800"></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>
{/* Calculated Totals for usage in Table Footer or Summary */}
{(() => {
const subTotal = data.items.reduce((acc, item) => {
const qty = parseFloat(item.quantity_received) || 0;
const price = parseFloat(item.unit_price) || 0;
return acc + (qty * price);
}, 0);
const taxAmount = Math.round(subTotal * 0.05);
const grandTotal = subTotal + taxAmount;
return (
<>
<div className="bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden">
<Table>
<TableHeader className="bg-gray-50/50">
<TableRow>
<TableHead className="w-[180px]"></TableHead>
<TableHead className="w-[80px] text-center"></TableHead>
<TableHead className="w-[80px] text-center"></TableHead>
<TableHead className="w-[120px]"> <span className="text-red-500">*</span></TableHead>
<TableHead className="w-[200px]"> <span className="text-red-500">*</span></TableHead>
<TableHead className="w-[150px]"></TableHead>
<TableHead className="w-[80px] text-right"></TableHead>
<TableHead className="w-[50px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.items.length === 0 ? (
<TableRow>
<TableCell colSpan={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;
const itemTotal = (parseFloat(item.quantity_received || 0) * parseFloat(item.unit_price || 0));
return (
<TableRow key={index} className="hover:bg-gray-50/50 text-sm">
{/* Product Info */}
<TableCell>
<div className="flex flex-col">
<span className="font-medium text-gray-900">{item.product_name}</span>
<span className="text-xs text-gray-500">{item.sku}</span>
</div>
</TableCell>
{/* Total Quantity */}
<TableCell className="text-center">
<span className="text-gray-500 text-sm">
{Math.round(item.quantity_ordered)}
</span>
</TableCell>
{/* Remaining */}
<TableCell className="text-center">
<span className="text-gray-900 font-medium text-sm">
{Math.round(item.quantity_ordered - item.quantity_received_so_far)}
</span>
</TableCell>
{/* Received Quantity */}
<TableCell>
<Input
type="number"
step="1"
min="0"
value={item.quantity_received}
onChange={(e) => updateItem(index, 'quantity_received', e.target.value)}
className={`w-full ${(errors as any)[errorKey] ? 'border-red-500' : ''}`}
/>
{(errors as any)[errorKey] && (
<p className="text-xs text-red-500 mt-1">{(errors as any)[errorKey]}</p>
)}
</TableCell>
{/* Batch Settings */}
<TableCell>
<div className="flex gap-2 items-center">
<Input
value={item.originCountry || 'TW'}
onChange={(e) => updateItem(index, 'originCountry', e.target.value.toUpperCase().slice(0, 2))}
placeholder="產地"
maxLength={2}
className="w-16 text-center px-1"
/>
<div className="flex-1 text-sm font-mono bg-gray-50 px-3 py-2 rounded text-gray-600 truncate">
{getBatchPreview(item.product_id, item.sku, item.originCountry || 'TW', data.received_date)}
</div>
</div>
</TableCell>
{/* Expiry Date */}
<TableCell>
<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={item.expiry_date}
onChange={(e) => updateItem(index, 'expiry_date', e.target.value)}
className={`pl-9 ${item.batchMode === 'existing' ? 'bg-gray-50' : ''}`}
disabled={item.batchMode === 'existing'}
/>
</div>
</TableCell>
{/* Subtotal */}
<TableCell className="text-right font-medium">
${itemTotal.toLocaleString()}
</TableCell>
{/* Actions */}
<TableCell>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => removeItem(index)}
className="hover:bg-red-50 hover:text-red-600 h-8 w-8"
>
<Trash2 className="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
);
})
)}
</TableBody>
</Table>
</div>
<div className="mt-6 flex justify-end">
<div className="w-full max-w-sm bg-primary/5 px-6 py-4 rounded-xl border border-primary/10 flex flex-col gap-3">
<div className="flex justify-between items-center w-full">
<span className="text-sm text-gray-500 font-medium"></span>
<span className="text-lg font-bold text-gray-700">${subTotal.toLocaleString()}</span>
</div>
<div className="flex justify-between items-center w-full">
<span className="text-sm text-gray-500 font-medium"> (5%)</span>
<span className="text-lg font-bold text-gray-700">${taxAmount.toLocaleString()}</span>
</div>
<div className="h-px bg-primary/10 w-full my-1"></div>
<div className="flex justify-between items-end w-full">
<span className="text-sm text-gray-500 font-medium mb-1"></span>
<span className="text-2xl font-black text-primary">
${grandTotal.toLocaleString()}
</span>
</div>
</div>
</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 >
);
}