feat: 統一進貨單 UI、修復庫存異動紀錄與廠商詳情顯示報錯
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Successful in 51s
Koori-ERP-Deploy-System / deploy-production (push) Has been skipped

This commit is contained in:
2026-01-27 17:23:31 +08:00
parent a7c445bd3f
commit 95d8dc2e84
24 changed files with 1613 additions and 466 deletions

View File

@@ -0,0 +1,101 @@
import { useState } from "react";
import { Eye, Trash2 } from "lucide-react";
import { Button } from "@/Components/ui/button";
import { Link, useForm } from "@inertiajs/react";
import { toast } from "sonner";
import { Can } from "@/Components/Permission/Can";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/Components/ui/alert-dialog";
export interface GoodsReceipt {
id: number;
code: string;
warehouse_id: number;
warehouse?: { name: string };
vendor_id?: number;
vendor?: { name: string };
received_date: string;
status: string;
type?: string;
items_sum_total_amount?: number;
user?: { name: string };
}
export default function GoodsReceiptActions({
receipt,
}: { receipt: GoodsReceipt }) {
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const { delete: destroy, processing } = useForm({});
const handleConfirmDelete = () => {
// @ts-ignore
destroy(route('goods-receipts.destroy', receipt.id), {
onSuccess: () => {
toast.success("進貨單已成功刪除");
setShowDeleteDialog(false);
},
onError: (errors: any) => toast.error(errors.error || "刪除過程中發生錯誤"),
});
};
return (
<div className="flex justify-center gap-2">
<Link href={route('goods-receipts.show', receipt.id)}>
<Button
variant="outline"
size="sm"
className="button-outlined-primary"
title="查看詳情"
>
<Eye className="h-4 w-4" />
</Button>
</Link>
{/* Delete typically restricted for Goods Receipts, checking permission */}
<Can permission="goods_receipts.delete">
<Button
variant="outline"
size="sm"
className="button-outlined-error"
title="刪除"
onClick={() => setShowDeleteDialog(true)}
disabled={processing}
>
<Trash2 className="h-4 w-4" />
</Button>
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle>
<AlertDialogDescription>
{receipt.code}
<br />
<span className="text-red-500 font-bold mt-2 block">
</span>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel className="button-outlined-primary"></AlertDialogCancel>
<AlertDialogAction
onClick={handleConfirmDelete}
className="button-filled-error"
>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</Can>
</div>
);
}

View File

@@ -0,0 +1,46 @@
import { Badge } from "@/Components/ui/badge";
export type GoodsReceiptStatus = 'processing' | 'completed' | 'cancelled';
export const GOODS_RECEIPT_STATUS_CONFIG: Record<string, { label: string; variant: "default" | "secondary" | "destructive" | "outline" | "success" | "warning" }> = {
processing: { label: "處理中", variant: "warning" },
completed: { label: "已完成", variant: "success" },
cancelled: { label: "已取消", variant: "destructive" },
};
interface GoodsReceiptStatusBadgeProps {
status: string;
className?: string;
}
export default function GoodsReceiptStatusBadge({
status,
className,
}: GoodsReceiptStatusBadgeProps) {
const config = GOODS_RECEIPT_STATUS_CONFIG[status] || { label: "未知", variant: "outline" };
// Apply custom styling based on variant mapping if not using standard badge variants
let badgeClass = "";
switch (config.variant) {
case "success":
badgeClass = "bg-green-100 text-green-800 hover:bg-green-200 border-green-200";
break;
case "warning":
badgeClass = "bg-yellow-100 text-yellow-800 hover:bg-yellow-200 border-yellow-200";
break;
case "destructive":
badgeClass = "bg-red-100 text-red-800 hover:bg-red-200 border-red-200";
break;
default:
badgeClass = "bg-gray-100 text-gray-800 hover:bg-gray-200 border-gray-200";
}
return (
<Badge
variant="outline"
className={`${className} font-medium px-2.5 py-0.5 rounded-full border ${badgeClass}`}
>
{config.label}
</Badge>
);
}

View File

@@ -0,0 +1,258 @@
/**
* 進貨單列表表格
*/
import { useState, useMemo } from "react";
import { ArrowUpDown, ArrowUp, ArrowDown } from "lucide-react";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/Components/ui/table";
import GoodsReceiptActions, { GoodsReceipt } from "./GoodsReceiptActions";
import GoodsReceiptStatusBadge from "./GoodsReceiptStatusBadge";
import CopyButton from "@/Components/shared/CopyButton";
import { formatCurrency, formatDate } from "@/utils/format";
interface GoodsReceiptTableProps {
receipts: GoodsReceipt[];
}
type SortField = "code" | "type" | "warehouse_name" | "vendor_name" | "received_date" | "total_amount" | "status";
type SortDirection = "asc" | "desc" | null;
export default function GoodsReceiptTable({
receipts,
}: GoodsReceiptTableProps) {
const [sortField, setSortField] = useState<SortField | null>(null);
const [sortDirection, setSortDirection] = useState<SortDirection>(null);
// 處理排序
const handleSort = (field: SortField) => {
if (sortField === field) {
if (sortDirection === "asc") {
setSortDirection("desc");
} else if (sortDirection === "desc") {
setSortDirection(null);
setSortField(null);
} else {
setSortDirection("asc");
}
} else {
setSortField(field);
setSortDirection("asc");
}
};
// 類型翻譯映射
const typeMap: Record<string, string> = {
standard: "標準採購",
miscellaneous: "雜項入庫",
other: "其他入庫",
};
// 排序後的進貨單列表
const sortedReceipts = useMemo(() => {
if (!sortField || !sortDirection) {
return receipts;
}
return [...receipts].sort((a, b) => {
let aValue: string | number;
let bValue: string | number;
switch (sortField) {
case "code":
aValue = a.code;
bValue = b.code;
break;
case "type":
aValue = typeMap[a.status] || a.status; // status here might actually refer to type in existing code logic? Let's use a.type if it exists.
// Checking if 'type' is in receipt - based on implementation plan we want it.
// Currently GoodsReceipt model HAS type.
// @ts-ignore
aValue = typeMap[a.type] || a.type || "";
// @ts-ignore
bValue = typeMap[b.type] || b.type || "";
break;
case "warehouse_name":
aValue = a.warehouse?.name || "";
bValue = b.warehouse?.name || "";
break;
case "vendor_name":
aValue = a.vendor?.name || "";
bValue = b.vendor?.name || "";
break;
case "received_date":
aValue = a.received_date;
bValue = b.received_date;
break;
case "total_amount":
aValue = a.items_sum_total_amount || 0;
bValue = b.items_sum_total_amount || 0;
break;
case "status":
aValue = a.status;
bValue = b.status;
break;
default:
return 0;
}
if (typeof aValue === "string" && typeof bValue === "string") {
return sortDirection === "asc"
? aValue.localeCompare(bValue, "zh-TW")
: bValue.localeCompare(aValue, "zh-TW");
} else {
return sortDirection === "asc"
? (aValue as number) - (bValue as number)
: (bValue as number) - (aValue as number);
}
});
}, [receipts, sortField, sortDirection]);
const SortIcon = ({ field }: { field: SortField }) => {
if (sortField !== field) {
return <ArrowUpDown className="h-4 w-4 text-muted-foreground" />;
}
if (sortDirection === "asc") {
return <ArrowUp className="h-4 w-4 text-primary" />;
}
if (sortDirection === "desc") {
return <ArrowDown className="h-4 w-4 text-primary" />;
}
return <ArrowUpDown className="h-4 w-4 text-muted-foreground" />;
};
return (
<div className="bg-white rounded-lg border shadow-sm overflow-hidden mt-6">
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow className="bg-gray-50/50">
<TableHead className="w-[50px] text-center">#</TableHead>
<TableHead className="w-[180px]">
<button
onClick={() => handleSort("code")}
className="flex items-center gap-2 hover:text-foreground transition-colors"
>
<SortIcon field="code" />
</button>
</TableHead>
<TableHead className="w-[120px]">
<button
onClick={() => handleSort("type")}
className="flex items-center gap-2 hover:text-foreground transition-colors"
>
<SortIcon field="type" />
</button>
</TableHead>
<TableHead className="w-[180px]">
<button
onClick={() => handleSort("warehouse_name")}
className="flex items-center gap-2 hover:text-foreground transition-colors"
>
<SortIcon field="warehouse_name" />
</button>
</TableHead>
<TableHead className="w-[180px]">
<button
onClick={() => handleSort("vendor_name")}
className="flex items-center gap-2 hover:text-foreground transition-colors"
>
<SortIcon field="vendor_name" />
</button>
</TableHead>
<TableHead className="w-[150px]">
<button
onClick={() => handleSort("received_date")}
className="flex items-center gap-2 hover:text-foreground transition-colors"
>
<SortIcon field="received_date" />
</button>
</TableHead>
<TableHead className="w-[140px] text-right">
<button
onClick={() => handleSort("total_amount")}
className="flex items-center gap-2 ml-auto hover:text-foreground transition-colors"
>
<SortIcon field="total_amount" />
</button>
</TableHead>
<TableHead className="w-[120px] text-center">
<button
onClick={() => handleSort("status")}
className="flex items-center gap-2 mx-auto hover:text-foreground transition-colors"
>
<SortIcon field="status" />
</button>
</TableHead>
<TableHead className="text-center"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{sortedReceipts.length === 0 ? (
<TableRow>
<TableCell colSpan={9} className="text-center text-muted-foreground py-12">
</TableCell>
</TableRow>
) : (
sortedReceipts.map((receipt, index) => (
<TableRow key={receipt.id}>
<TableCell className="text-gray-500 font-medium text-center">
{index + 1}
</TableCell>
<TableCell>
<div className="flex items-center gap-1.5">
<span className="font-mono text-sm font-medium">{receipt.code}</span>
<CopyButton text={receipt.code} label="複製單號" />
</div>
</TableCell>
<TableCell>
<span className="text-sm">
{/* @ts-ignore */}
{typeMap[receipt.type] || receipt.type || "-"}
</span>
</TableCell>
<TableCell>
<div className="text-sm font-medium text-gray-900">
{receipt.warehouse?.name || "-"}
</div>
</TableCell>
<TableCell>
<span className="text-sm text-gray-700">{receipt.vendor?.name || "-"}</span>
</TableCell>
<TableCell>
<span className="text-sm text-gray-500">{formatDate(receipt.received_date)}</span>
</TableCell>
<TableCell className="text-right">
<span className="font-semibold text-gray-900">
{formatCurrency(receipt.items_sum_total_amount)}
</span>
</TableCell>
<TableCell className="text-center">
<GoodsReceiptStatusBadge status={receipt.status} />
</TableCell>
<TableCell className="text-center">
<GoodsReceiptActions receipt={receipt} />
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</div>
);
}

View File

@@ -4,6 +4,7 @@
import { Badge } from "@/Components/ui/badge";
import { PurchaseOrderStatus } from "@/types/purchase-order";
import { STATUS_CONFIG } from "@/constants/purchase-order";
interface PurchaseOrderStatusBadgeProps {
status: PurchaseOrderStatus;
@@ -14,35 +15,12 @@ export default function PurchaseOrderStatusBadge({
status,
className,
}: PurchaseOrderStatusBadgeProps) {
const getStatusConfig = (status: PurchaseOrderStatus) => {
switch (status) {
case "draft":
return { label: "草稿", className: "bg-gray-100 text-gray-700 border-gray-200" };
case "pending":
return { label: "待審核", className: "bg-blue-100 text-blue-700 border-blue-200" };
case "processing":
return { label: "處理中", className: "bg-yellow-100 text-yellow-700 border-yellow-200" };
case "shipping":
return { label: "運送中", className: "bg-purple-100 text-purple-700 border-purple-200" };
case "confirming":
return { label: "待確認", className: "bg-orange-100 text-orange-700 border-orange-200" };
case "completed":
return { label: "已完成", className: "bg-green-100 text-green-700 border-green-200" };
case "cancelled":
return { label: "已取消", className: "bg-red-100 text-red-700 border-red-200" };
case "partial":
return { label: "部分進貨", className: "bg-blue-50 text-blue-600 border-blue-100" };
default:
return { label: "未知", className: "bg-gray-100 text-gray-700 border-gray-200" };
}
};
const config = getStatusConfig(status);
const config = STATUS_CONFIG[status] || { label: "未知", variant: "outline" };
return (
<Badge
variant="outline"
className={`${config.className} ${className} font-medium px-2.5 py-0.5 rounded-full`}
variant={config.variant}
className={`${className} font-medium px-2.5 py-0.5 rounded-full`}
>
{config.label}
</Badge>

View File

@@ -10,13 +10,13 @@ interface StatusProgressBarProps {
}
// 流程步驟定義
const FLOW_STEPS: { key: PurchaseOrderStatus | "approved"; label: string }[] = [
const FLOW_STEPS: { key: PurchaseOrderStatus; label: string }[] = [
{ key: "draft", label: "草稿" },
{ key: "pending", label: "待審核" },
{ key: "processing", label: "處理中" },
{ key: "shipping", label: "運送中" },
{ key: "confirming", label: "待確認" },
{ key: "completed", label: "已完成" },
{ key: "pending", label: "簽核中" },
{ key: "approved", label: "已核准" },
{ key: "partial", label: "部分收貨" },
{ key: "completed", label: "全數收貨" },
{ key: "closed", label: "已結案" },
];
export function StatusProgressBar({ currentStatus }: StatusProgressBarProps) {
@@ -82,7 +82,7 @@ export function StatusProgressBar({ currentStatus }: StatusProgressBarProps) {
: "text-gray-400"
}`}
>
{isRejectedAtThisStep ? "已取消" : step.label}
{isRejectedAtThisStep ? "已作廢" : step.label}
</p>
</div>
</div>

View File

@@ -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 >
);
}

View File

@@ -1,29 +1,122 @@
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout';
import { Head, Link, router } from '@inertiajs/react';
import { Button } from '@/Components/ui/button';
import { Plus, Search, FileText } from 'lucide-react';
import { Plus, Search, FileText, RotateCcw, Calendar, ChevronDown, ChevronUp } from 'lucide-react';
import { Input } from '@/Components/ui/input';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/Components/ui/table';
import { Badge } from '@/Components/ui/badge';
import { Label } from '@/Components/ui/label';
import { SearchableSelect } from '@/Components/ui/searchable-select';
import Pagination from '@/Components/shared/Pagination';
import { useState } from 'react';
import { useState, useEffect } from 'react';
import { Can } from '@/Components/Permission/Can';
import { getDateRange } from '@/utils/format';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/Components/ui/select';
import GoodsReceiptTable from '@/Components/Inventory/GoodsReceiptTable';
export default function GoodsReceiptIndex({ receipts, filters }: any) {
interface Warehouse {
id: number;
name: string;
type: string;
}
interface Filters {
search?: string;
status?: string;
warehouse_id?: string;
date_start?: string;
date_end?: string;
per_page?: string;
}
interface Props {
receipts: any;
filters: Filters;
warehouses: Warehouse[];
}
export default function GoodsReceiptIndex({ receipts, filters, warehouses }: Props) {
const [search, setSearch] = useState(filters.search || '');
const [status, setStatus] = useState(filters.status || 'all');
const [warehouseId, setWarehouseId] = useState(filters.warehouse_id || 'all');
const [dateStart, setDateStart] = useState(filters.date_start || '');
const [dateEnd, setDateEnd] = useState(filters.date_end || '');
const [perPage, setPerPage] = useState(filters.per_page || '10');
const [dateRangeType, setDateRangeType] = useState('custom');
const handleSearch = (e: React.FormEvent) => {
e.preventDefault();
router.get(route('goods-receipts.index'), { search }, { preserveState: true });
// Advanced Filter Toggle
const [showAdvanced, setShowAdvanced] = useState(
!!(filters.date_start || filters.date_end)
);
// Sync filters from props
useEffect(() => {
setSearch(filters.search || '');
setStatus(filters.status || 'all');
setWarehouseId(filters.warehouse_id || 'all');
setDateStart(filters.date_start || '');
setDateEnd(filters.date_end || '');
setPerPage(filters.per_page || '10');
}, [filters]);
const handleFilter = () => {
router.get(route('goods-receipts.index'), {
search,
status: status !== 'all' ? status : undefined,
warehouse_id: warehouseId !== 'all' ? warehouseId : undefined,
date_start: dateStart || undefined,
date_end: dateEnd || undefined,
per_page: perPage,
}, { preserveState: true, replace: true });
};
const handleReset = () => {
setSearch('');
setStatus('all');
setWarehouseId('all');
setDateStart('');
setDateEnd('');
setDateRangeType('custom');
setPerPage('10');
router.get(route('goods-receipts.index'), {}, { preserveState: false });
};
const handleDateRangeChange = (type: string) => {
setDateRangeType(type);
if (type === 'custom') return;
const { start, end } = getDateRange(type);
setDateStart(start);
setDateEnd(end);
};
const handlePerPageChange = (value: string) => {
setPerPage(value);
router.get(route('goods-receipts.index'), {
search,
status: status !== 'all' ? status : undefined,
warehouse_id: warehouseId !== 'all' ? warehouseId : undefined,
date_start: dateStart || undefined,
date_end: dateEnd || undefined,
per_page: value,
}, { preserveState: true, preserveScroll: true, replace: true });
};
const statusOptions = [
{ label: '全部狀態', value: 'all' },
{ label: '已完成', value: 'completed' },
{ label: '處理中', value: 'processing' },
];
const warehouseOptions = [
{ label: '全部倉庫', value: 'all' },
...warehouses.map(w => ({ label: w.name, value: w.id.toString() }))
];
return (
<AuthenticatedLayout
breadcrumbs={[
@@ -56,79 +149,177 @@ export default function GoodsReceiptIndex({ receipts, filters }: any) {
</div>
{/* Filter Bar */}
<div className="bg-white p-4 rounded-xl border border-gray-200 mb-6 shadow-sm">
<form onSubmit={handleSearch} className="flex gap-4 items-end">
<div className="space-y-1">
<label className="text-xs font-medium text-gray-500"></label>
<div className="flex gap-2">
<div className="bg-white p-5 rounded-lg shadow-sm border border-gray-200 mb-6">
{/* Row 1: Search, Status, Warehouse */}
<div className="grid grid-cols-1 md:grid-cols-12 gap-4 mb-4">
<div className="md:col-span-4 space-y-1">
<Label className="text-xs font-medium text-grey-1"></Label>
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 h-4 w-4" />
<Input
type="text"
placeholder="搜尋單號..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-64 h-9"
className="pl-10 h-9 block"
onKeyDown={(e) => e.key === 'Enter' && handleFilter()}
/>
<Button type="submit" variant="outline" size="sm" className="h-9 w-9 p-0 button-outlined-primary">
<Search className="h-4 w-4" />
</Button>
</div>
</div>
</form>
<div className="md:col-span-4 space-y-1">
<Label className="text-xs font-medium text-grey-1"></Label>
<Select value={status} onValueChange={setStatus}>
<SelectTrigger className="h-9">
<SelectValue placeholder="選擇狀態" />
</SelectTrigger>
<SelectContent>
{statusOptions.map(opt => (
<SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="md:col-span-4 space-y-1">
<Label className="text-xs font-medium text-grey-1"></Label>
<SearchableSelect
value={warehouseId}
onValueChange={setWarehouseId}
options={warehouseOptions}
placeholder="選擇倉庫"
className="w-full h-9"
showSearch={warehouses.length > 10}
/>
</div>
</div>
{/* Row 2: Date Filters (Collapsible) */}
{showAdvanced && (
<div className="grid grid-cols-1 md:grid-cols-12 gap-4 items-end animate-in fade-in slide-in-from-top-2 duration-200">
<div className="md:col-span-6 space-y-2">
<Label className="text-xs font-medium text-grey-1"></Label>
<div className="flex flex-wrap gap-2">
{[
{ label: "今日", value: "today" },
{ label: "昨日", value: "yesterday" },
{ label: "本週", value: "this_week" },
{ label: "本月", value: "this_month" },
{ label: "上月", value: "last_month" },
].map((opt) => (
<Button
key={opt.value}
size="sm"
onClick={() => handleDateRangeChange(opt.value)}
className={
dateRangeType === opt.value
? 'button-filled-primary h-9 px-4 shadow-sm'
: 'button-outlined-primary h-9 px-4 bg-white'
}
>
{opt.label}
</Button>
))}
</div>
</div>
<div className="md:col-span-6">
<div className="grid grid-cols-2 gap-4 items-end">
<div className="space-y-1">
<Label className="text-xs text-grey-2 font-medium"></Label>
<div className="relative">
<Calendar className="absolute left-2.5 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400 pointer-events-none" />
<Input
type="date"
value={dateStart}
onChange={(e) => {
setDateStart(e.target.value);
setDateRangeType('custom');
}}
className="pl-9 block w-full h-9 bg-white"
/>
</div>
</div>
<div className="space-y-1">
<Label className="text-xs text-grey-2 font-medium"></Label>
<div className="relative">
<Calendar className="absolute left-2.5 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400 pointer-events-none" />
<Input
type="date"
value={dateEnd}
onChange={(e) => {
setDateEnd(e.target.value);
setDateRangeType('custom');
}}
className="pl-9 block w-full h-9 bg-white text-left"
/>
</div>
</div>
</div>
</div>
</div>
)}
<div className="flex items-center justify-end border-t border-gray-100 pt-5 gap-3 mt-4">
<Button
variant="ghost"
size="sm"
onClick={() => setShowAdvanced(!showAdvanced)}
className="mr-auto text-gray-500 hover:text-gray-900 h-9"
>
{showAdvanced ? (
<>
<ChevronUp className="h-4 w-4 mr-1" />
</>
) : (
<>
<ChevronDown className="h-4 w-4 mr-1" />
{(dateStart || dateEnd) && (
<span className="ml-2 w-2 h-2 rounded-full bg-primary-main" />
)}
</>
)}
</Button>
<Button
variant="outline"
onClick={handleReset}
className="flex items-center gap-2 button-outlined-primary h-9"
>
<RotateCcw className="h-4 w-4" />
</Button>
<Button
onClick={handleFilter}
className="flex items-center gap-2 button-filled-primary h-9 px-6"
>
<Search className="h-4 w-4" />
</Button>
</div>
</div>
{/* Table Section */}
<div className="bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden">
<Table>
<TableHeader className="bg-gray-50">
<TableRow>
<TableHead className="w-[180px]"></TableHead>
<TableHead></TableHead>
<TableHead>ID</TableHead>
<TableHead className="w-[120px] text-center"></TableHead>
<TableHead className="w-[100px] text-center"></TableHead>
<TableHead className="w-[100px] text-center"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{receipts.data.length === 0 ? (
<TableRow>
<TableCell colSpan={6} className="h-24 text-center text-gray-500">
</TableCell>
</TableRow>
) : (
receipts.data.map((receipt: any) => (
<TableRow key={receipt.id}>
<TableCell className="font-medium text-gray-900">{receipt.code}</TableCell>
<TableCell className="text-gray-600">{receipt.warehouse?.name}</TableCell>
<TableCell className="text-gray-600">{receipt.vendor_id}</TableCell>
<TableCell className="text-center text-gray-600">{receipt.received_date}</TableCell>
<TableCell className="text-center">
<Badge variant="outline" className={
receipt.status === 'completed'
? 'bg-green-50 text-green-700 border-green-200'
: 'bg-gray-50 text-gray-700 border-gray-200'
}>
{receipt.status}
</Badge>
</TableCell>
<TableCell className="text-center">
<div className="flex items-center justify-center gap-2">
<Can permission="goods_receipts.view">
<Button variant="outline" size="sm" className="button-outlined-primary" title="查看詳情">
<FileText className="h-4 w-4" />
</Button>
</Can>
</div>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
<GoodsReceiptTable receipts={receipts.data} />
<div className="mt-6">
{/* Pagination */}
<div className="mt-4 flex flex-col sm:flex-row items-center justify-between gap-4">
<div className="flex items-center gap-2 text-sm text-gray-500">
<span></span>
<SearchableSelect
value={perPage}
onValueChange={handlePerPageChange}
options={[
{ label: "10", value: "10" },
{ label: "20", value: "20" },
{ label: "50", value: "50" },
{ label: "100", value: "100" }
]}
className="w-[100px] h-8"
showSearch={false}
/>
<span></span>
</div>
<Pagination links={receipts.links} />
</div>
</div>

View File

@@ -0,0 +1,221 @@
/**
* 查看進貨單詳情頁面
*/
import { ArrowLeft, Package } from "lucide-react";
import { Button } from "@/Components/ui/button";
import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout";
import { Head, Link } from "@inertiajs/react";
import GoodsReceiptStatusBadge from "@/Components/Inventory/GoodsReceiptStatusBadge";
import CopyButton from "@/Components/shared/CopyButton";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/Components/ui/table";
import { formatCurrency, formatDate, formatDateTime } from "@/utils/format";
import { getShowBreadcrumbs } from "@/utils/breadcrumb";
interface GoodsReceiptItem {
id: number;
product_id: number;
product: {
id: number;
name: string;
code: string;
baseUnit?: {
name: string;
};
};
quantity_received: string | number;
unit_price: string | number;
total_amount: string | number;
batch_number?: string;
expiry_date?: string;
}
interface GoodsReceipt {
id: number;
code: string;
type: string;
received_date: string;
status: string;
remark?: string;
warehouse?: {
name: string;
};
vendor?: {
name: string;
};
items: GoodsReceiptItem[];
items_sum_total_amount: number;
created_at: string;
}
interface Props {
receipt: GoodsReceipt;
}
export default function ViewGoodsReceiptPage({ receipt }: Props) {
const typeMap: Record<string, string> = {
standard: "標準採購進貨",
miscellaneous: "雜項入庫",
other: "其他入庫",
};
return (
<AuthenticatedLayout breadcrumbs={getShowBreadcrumbs("goodsReceipts", `詳情 (#${receipt.code})`)}>
<Head title={`進貨單詳情 - ${receipt.code}`} />
<div className="container mx-auto p-6 max-w-7xl">
{/* Header */}
<div className="mb-6">
<Link href="/goods-receipts">
<Button
variant="outline"
className="gap-2 button-outlined-primary mb-6"
>
<ArrowLeft className="h-4 w-4" />
</Button>
</Link>
<div className="flex items-center justify-between mb-6">
<div>
<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">{receipt.code}</p>
</div>
<div className="flex items-center gap-3">
<GoodsReceiptStatusBadge status={receipt.status} />
</div>
</div>
</div>
<div className="grid grid-cols-1 gap-8">
{/* 基本資訊卡片 */}
<div className="bg-white rounded-lg border shadow-sm p-6">
<h2 className="text-lg font-bold text-gray-900 mb-6 border-b pb-4"></h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-x-8 gap-y-6">
<div>
<span className="text-sm text-gray-500 block mb-1"></span>
<div className="flex items-center gap-1.5">
<span className="font-mono font-medium text-gray-900">{receipt.code}</span>
<CopyButton text={receipt.code} label="複製單號" />
</div>
</div>
<div>
<span className="text-sm text-gray-500 block mb-1"></span>
<span className="font-medium text-gray-900">{typeMap[receipt.type] || receipt.type}</span>
</div>
<div>
<span className="text-sm text-gray-500 block mb-1"></span>
<span className="font-medium text-gray-900">{receipt.warehouse?.name || "-"}</span>
</div>
<div>
<span className="text-sm text-gray-500 block mb-1"></span>
<span className="font-medium text-gray-900">{receipt.vendor?.name || "-"}</span>
</div>
<div>
<span className="text-sm text-gray-500 block mb-1"></span>
<span className="font-medium text-gray-900">{formatDate(receipt.received_date)}</span>
</div>
<div>
<span className="text-sm text-gray-500 block mb-1"></span>
<span className="font-medium text-gray-900">{formatDateTime(receipt.created_at)}</span>
</div>
</div>
{receipt.remark && (
<div className="mt-8 pt-6 border-t border-gray-100">
<span className="text-sm text-gray-500 block mb-2"></span>
<p className="text-sm text-gray-700 bg-gray-50 p-4 rounded-lg leading-relaxed">
{receipt.remark}
</p>
</div>
)}
</div>
{/* 品項清單卡片 */}
<div className="bg-white rounded-lg border shadow-sm overflow-hidden">
<div className="p-6 border-b border-gray-100">
<h2 className="text-lg font-bold text-gray-900"></h2>
</div>
<div className="p-0">
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow className="bg-gray-50/50">
<TableHead className="w-[80px] text-center">#</TableHead>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead className="text-center"></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{receipt.items.length === 0 ? (
<TableRow>
<TableCell colSpan={8} className="h-24 text-center text-gray-500">
</TableCell>
</TableRow>
) : (
receipt.items.map((item, index) => (
<TableRow key={item.id}>
<TableCell className="text-center text-gray-500">{index + 1}</TableCell>
<TableCell>
<div className="flex flex-col">
<span className="font-medium text-gray-900">{item.product.name}</span>
<span className="text-xs text-gray-500 font-mono">{item.product.code}</span>
</div>
</TableCell>
<TableCell className="text-right font-medium">
{Number(item.quantity_received).toLocaleString()}
</TableCell>
<TableCell className="text-center">
{item.product.baseUnit?.name || "個"}
</TableCell>
<TableCell className="text-right">
{formatCurrency(Number(item.unit_price))}
</TableCell>
<TableCell className="text-right font-bold text-primary">
{formatCurrency(Number(item.total_amount))}
</TableCell>
<TableCell>
<span className="text-sm font-mono">{item.batch_number || "-"}</span>
</TableCell>
<TableCell>
<span className="text-sm">{item.expiry_date ? formatDate(item.expiry_date) : "-"}</span>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
{/* 總計 */}
<div className="p-6 border-t border-gray-100 flex justify-end">
<div className="w-full max-w-xs bg-gray-50/50 px-6 py-4 rounded-xl border border-gray-100 flex flex-col gap-3">
<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">
{formatCurrency(receipt.items_sum_total_amount)}
</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</AuthenticatedLayout>
);
}

View File

@@ -23,6 +23,7 @@ import {
SelectTrigger,
SelectValue,
} from "@/Components/ui/select";
import { STATUS_OPTIONS } from "@/constants/purchase-order";
interface Props {
orders: {
@@ -176,14 +177,11 @@ export default function PurchaseOrderIndex({ orders, filters, warehouses }: Prop
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
<SelectItem value="draft">稿</SelectItem>
<SelectItem value="pending"></SelectItem>
<SelectItem value="processing"></SelectItem>
<SelectItem value="shipping"></SelectItem>
<SelectItem value="confirming"></SelectItem>
<SelectItem value="completed"></SelectItem>
<SelectItem value="partial"></SelectItem>
<SelectItem value="cancelled"></SelectItem>
{STATUS_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>

View File

@@ -147,20 +147,26 @@ export default function ViewPurchaseOrderPage({ order }: Props) {
items={order.items}
isReadOnly={true}
/>
<div className="mt-4 flex flex-col items-end gap-2 border-t pt-4">
<div className="flex items-center gap-8 text-gray-600">
<span className="font-medium"></span>
<span>{formatCurrency(order.totalAmount)}</span>
</div>
<div className="flex items-center gap-8 text-gray-600">
<span className="font-medium"></span>
<span>{formatCurrency(order.tax_amount || 0)}</span>
</div>
<div className="flex items-center gap-8 pt-2 mt-2 border-t border-gray-100">
<span className="font-bold text-lg"></span>
<span className="text-xl font-bold text-primary">
{formatCurrency(order.grand_total || (order.totalAmount + (order.tax_amount || 0)))}
</span>
<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">{formatCurrency(order.totalAmount)}</span>
</div>
<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">{formatCurrency(order.taxAmount || 0)}</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">
{formatCurrency(order.grandTotal || (order.totalAmount + (order.taxAmount || 0)))}
</span>
</div>
</div>
</div>
</div>

View File

@@ -10,13 +10,12 @@ export const STATUS_CONFIG: Record<
{ label: string; variant: "default" | "secondary" | "destructive" | "outline" }
> = {
draft: { label: "草稿", variant: "outline" },
pending: { label: "待審核", variant: "outline" },
processing: { label: "處理中", variant: "outline" },
shipping: { label: "運送中", variant: "outline" },
confirming: { label: "待確認", variant: "outline" },
completed: { label: "已完成", variant: "outline" },
cancelled: { label: "已取消", variant: "outline" },
partial: { label: "部分進貨", variant: "secondary" },
pending: { label: "簽核中", variant: "outline" },
approved: { label: "已核准", variant: "default" },
partial: { label: "部分收貨", variant: "secondary" },
completed: { label: "全數收貨", variant: "outline" },
closed: { label: "已結案", variant: "outline" },
cancelled: { label: "已作廢", variant: "destructive" },
};
export const STATUS_OPTIONS = Object.entries(STATUS_CONFIG).map(([value, config]) => ({

View File

@@ -0,0 +1,27 @@
export interface GoodsReceipt {
id: number;
code: string;
warehouse_id: number;
warehouse?: {
id: number;
name: string;
};
vendor_id?: number;
vendor?: {
id: number;
name: string;
};
purchase_order_id?: number;
purchase_order?: {
code: string; // If loaded
};
received_date: string;
status: 'completed' | 'processing' | 'cancelled';
remarks?: string;
items_sum_total_amount?: number; // Calculated field
created_at: string;
updated_at: string;
user?: {
name: string;
};
}

View File

@@ -4,14 +4,12 @@
export type PurchaseOrderStatus =
| "draft" // 草稿
| "pending" // 待審核
| "processing" // 處理中
| "shipping" // 運送中
| "confirming" // 待確認
| "completed" // 已完成
| "completed" // 已完成
| "cancelled" // 已取消
| "partial"; // 部分進貨
| "pending" // 簽核中
| "approved" // 已核准
| "partial" // 部分收貨
| "completed" // 全數收貨
| "closed" // 已結案
| "cancelled"; // 已作廢