feat: 統一進貨單 UI、修復庫存異動紀錄與廠商詳情顯示報錯
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout';
|
||||
import { Head, useForm } from '@inertiajs/react';
|
||||
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';
|
||||
@@ -10,7 +10,8 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/Components/ui/select';
|
||||
import { useState } from 'react';
|
||||
import { SearchableSelect } from '@/Components/ui/searchable-select';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -19,17 +20,9 @@ import {
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/Components/ui/table';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/Components/ui/alert-dialog";
|
||||
|
||||
import { Badge } from "@/Components/ui/badge";
|
||||
|
||||
|
||||
import {
|
||||
Search,
|
||||
@@ -40,35 +33,65 @@ import {
|
||||
Package
|
||||
} from 'lucide-react';
|
||||
import axios from 'axios';
|
||||
import { PurchaseOrderStatus } from '@/types/purchase-order';
|
||||
import { STATUS_CONFIG } from '@/constants/purchase-order';
|
||||
|
||||
interface POItem {
|
||||
|
||||
|
||||
interface BatchItem {
|
||||
inventoryId: string;
|
||||
batchNumber: string;
|
||||
originCountry: string;
|
||||
expiryDate: string | null;
|
||||
quantity: number;
|
||||
}
|
||||
|
||||
// 待進貨採購單 Item 介面
|
||||
interface PendingPOItem {
|
||||
id: number;
|
||||
product_id: number;
|
||||
product: { name: string; sku: string };
|
||||
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 PO {
|
||||
// 待進貨採購單介面
|
||||
interface PendingPO {
|
||||
id: number;
|
||||
code: string;
|
||||
status: PurchaseOrderStatus;
|
||||
vendor_id: number;
|
||||
vendor: { id: number; name: string };
|
||||
vendor_name: string;
|
||||
warehouse_id: number | null;
|
||||
items: POItem[];
|
||||
order_date: string;
|
||||
items: PendingPOItem[];
|
||||
}
|
||||
|
||||
export default function GoodsReceiptCreate({ warehouses }: { warehouses: any[] }) {
|
||||
const [poSearch, setPoSearch] = useState('');
|
||||
const [foundPOs, setFoundPOs] = useState<PO[]>([]);
|
||||
const [selectedPO, setSelectedPO] = useState<PO | null>(null);
|
||||
// 廠商介面
|
||||
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 Selection States
|
||||
const [vendorSearch, setVendorSearch] = useState('');
|
||||
const [foundVendors, setFoundVendors] = useState<any[]>([]);
|
||||
const [selectedVendor, setSelectedVendor] = useState<any | null>(null);
|
||||
// Manual Product Search States
|
||||
const [productSearch, setProductSearch] = useState('');
|
||||
const [foundProducts, setFoundProducts] = useState<any[]>([]);
|
||||
|
||||
@@ -82,36 +105,7 @@ export default function GoodsReceiptCreate({ warehouses }: { warehouses: any[] }
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
// 搜尋商品 API(用於雜項入庫/其他類型)
|
||||
const searchProducts = async () => {
|
||||
if (!productSearch) return;
|
||||
setIsSearching(true);
|
||||
@@ -127,24 +121,25 @@ export default function GoodsReceiptCreate({ warehouses }: { warehouses: any[] }
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectPO = (po: PO) => {
|
||||
// 選擇採購單
|
||||
const handleSelectPO = (po: PendingPO) => {
|
||||
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: '',
|
||||
};
|
||||
});
|
||||
// 將採購單項目轉換為進貨單項目,預填剩餘可收貨量
|
||||
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,
|
||||
@@ -153,13 +148,15 @@ export default function GoodsReceiptCreate({ warehouses }: { warehouses: any[] }
|
||||
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 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) => {
|
||||
@@ -170,6 +167,8 @@ export default function GoodsReceiptCreate({ warehouses }: { warehouses: any[] }
|
||||
quantity_received: 0,
|
||||
unit_price: product.price || 0,
|
||||
batch_number: '',
|
||||
batchMode: 'new',
|
||||
originCountry: 'TW',
|
||||
expiry_date: '',
|
||||
};
|
||||
setData('items', [...data.items, newItem]);
|
||||
@@ -189,11 +188,118 @@ export default function GoodsReceiptCreate({ warehouses }: { warehouses: any[] }
|
||||
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={[
|
||||
@@ -207,9 +313,12 @@ export default function GoodsReceiptCreate({ warehouses }: { warehouses: any[] }
|
||||
<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>
|
||||
<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">
|
||||
@@ -262,11 +371,11 @@ export default function GoodsReceiptCreate({ warehouses }: { warehouses: any[] }
|
||||
{/* 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'}`}>
|
||||
<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">
|
||||
<h2 className="text-lg font-bold text-gray-800">
|
||||
{data.type === 'standard' ? '選擇來源採購單' : '選擇供應商'}
|
||||
</h2>
|
||||
</div>
|
||||
@@ -275,41 +384,40 @@ export default function GoodsReceiptCreate({ warehouses }: { warehouses: any[] }
|
||||
{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>
|
||||
<Label className="text-sm font-medium text-gray-700">請選擇待進貨的採購單</Label>
|
||||
|
||||
{foundPOs.length > 0 && (
|
||||
{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>供應商</TableHead>
|
||||
<TableHead className="text-center">狀態</TableHead>
|
||||
<TableHead className="text-center">待收項目</TableHead>
|
||||
<TableHead className="w-[100px] text-center">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{foundPOs.map((po) => (
|
||||
<TableRow key={po.id}>
|
||||
{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>{po.vendor_name}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<Button size="sm" onClick={() => handleSelectPO(po)} className="button-outlined-primary">
|
||||
帶入
|
||||
<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>
|
||||
@@ -328,7 +436,11 @@ export default function GoodsReceiptCreate({ warehouses }: { warehouses: any[] }
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-xs text-gray-500 block">供應商</span>
|
||||
<span className="font-bold text-gray-800">{selectedPO.vendor?.name}</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">
|
||||
@@ -339,47 +451,23 @@ export default function GoodsReceiptCreate({ warehouses }: { warehouses: any[] }
|
||||
) : (
|
||||
!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 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>
|
||||
|
||||
{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>
|
||||
{vendors.length === 0 && (
|
||||
<div className="text-center py-8 text-gray-500 bg-gray-50 rounded-lg border border-dashed">
|
||||
目前沒有可選擇的供應商
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -408,8 +496,8 @@ export default function GoodsReceiptCreate({ warehouses }: { warehouses: any[] }
|
||||
{((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 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">
|
||||
@@ -491,125 +579,163 @@ export default function GoodsReceiptCreate({ warehouses }: { warehouses: any[] }
|
||||
)}
|
||||
</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>
|
||||
{/* 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
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="button-outlined-error"
|
||||
title="移除項目"
|
||||
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>
|
||||
</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>
|
||||
</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>
|
||||
@@ -632,6 +758,6 @@ export default function GoodsReceiptCreate({ warehouses }: { warehouses: any[] }
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</AuthenticatedLayout>
|
||||
</AuthenticatedLayout >
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user