feat(procurement): 實作採購退回單模組並修復商品選單報錯
All checks were successful
ERP-Deploy-Demo / deploy-demo (push) Successful in 58s

This commit is contained in:
2026-02-25 13:49:02 +08:00
parent deef3baacc
commit c4908533a8
21 changed files with 2409 additions and 1 deletions

View File

@@ -0,0 +1,100 @@
import { useState } from "react";
import { Pencil, Eye, Trash2 } from "lucide-react";
import { Button } from "@/Components/ui/button";
import { Link, useForm } from "@inertiajs/react";
import type { PurchaseReturn } from "@/types/purchase-return";
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 function PurchaseReturnActions({
purchaseReturn,
}: { purchaseReturn: PurchaseReturn }) {
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const { delete: destroy, processing } = useForm({});
const handleConfirmDelete = () => {
// @ts-ignore
destroy(route('purchase-returns.destroy', purchaseReturn.id), {
onSuccess: () => {
toast.success("採購退回單已成功刪除");
setShowDeleteDialog(false);
},
onError: (errors: any) => toast.error(errors.error || "刪除過程中發生錯誤"),
});
};
return (
<div className="flex justify-center gap-2">
<Link href={`/purchase-returns/${purchaseReturn.id}`}>
<Button
variant="outline"
size="sm"
className="button-outlined-primary"
title="查看詳情"
>
<Eye className="h-4 w-4" />
</Button>
</Link>
{purchaseReturn.status === 'draft' && (
<Can permission="purchase_returns.edit">
<Link href={`/purchase-returns/${purchaseReturn.id}/edit`}>
<Button
variant="outline"
size="sm"
className="button-outlined-primary"
title="編輯"
>
<Pencil className="h-4 w-4" />
</Button>
</Link>
</Can>
)}
{purchaseReturn.status === 'draft' && (
<Can permission="purchase_returns.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>
退 {purchaseReturn.code}
</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,214 @@
import { Trash2 } from "lucide-react";
import { Button } from "@/Components/ui/button";
import { Input } from "@/Components/ui/input";
import { SearchableSelect } from "@/Components/ui/searchable-select";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/Components/ui/table";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/Components/ui/alert-dialog";
import type { PurchaseReturnItem } from "@/types/purchase-return";
import type { Supplier } from "@/types/purchase-order";
import { formatCurrency } from "@/utils/format";
interface PurchaseReturnItemsTableProps {
items: PurchaseReturnItem[];
vendor?: Supplier;
isReadOnly?: boolean;
isDisabled?: boolean;
onRemoveItem?: (index: number) => void;
onItemChange?: (index: number, field: keyof PurchaseReturnItem, value: string | number) => void;
}
export function PurchaseReturnItemsTable({
items,
vendor,
isReadOnly = false,
isDisabled = false,
onRemoveItem,
onItemChange,
}: PurchaseReturnItemsTableProps) {
return (
<div className={`border rounded-lg overflow-hidden ${isDisabled ? "opacity-50 pointer-events-none grayscale" : ""}`}>
<Table>
<TableHeader>
<TableRow className="bg-gray-50 hover:bg-gray-50">
<TableHead className="w-[30%] text-left">退</TableHead>
<TableHead className="w-[15%] text-left">退</TableHead>
<TableHead className="w-[15%] text-left">退</TableHead>
<TableHead className="w-[15%] text-left">退</TableHead>
<TableHead className="w-[20%] text-left"> / </TableHead>
{!isReadOnly && <TableHead className="w-[5%]"></TableHead>}
</TableRow>
</TableHeader>
<TableBody>
{items.length === 0 ? (
<TableRow>
<TableCell
colSpan={isReadOnly ? 5 : 6}
className="text-center text-gray-400 py-12 italic"
>
{isDisabled ? "請先選擇供應商後才能加入退回商品" : "尚未新增任何退回品項"}
</TableCell>
</TableRow>
) : (
items.map((item, index) => {
return (
<TableRow key={index}>
{/* 商品選擇 */}
<TableCell>
{isReadOnly ? (
<span className="font-medium">{item.product?.name || item.product_name}</span>
) : (
<SearchableSelect
value={String(item.product_id)}
onValueChange={(value) =>
onItemChange?.(index, "product_id", value)
}
disabled={isDisabled}
options={vendor?.commonProducts?.map((p) => ({ label: p.productName, value: String(p.productId) })) || []}
placeholder="選擇退回商品"
searchPlaceholder="搜尋商品..."
emptyText="此供應商無可用商品"
className="w-full"
/>
)}
</TableCell>
{/* 數量 */}
<TableCell className="text-left">
{isReadOnly ? (
<span>{item.quantity_returned}</span>
) : (
<Input
type="number"
min="0.01"
step="any"
value={item.quantity_returned || ""}
onChange={(e) =>
onItemChange?.(index, "quantity_returned", Number(e.target.value))
}
disabled={isDisabled}
className="text-right w-full"
/>
)}
</TableCell>
{/* 單價 */}
<TableCell className="text-left">
{isReadOnly ? (
<span>{formatCurrency(item.unit_price)}</span>
) : (
<Input
type="number"
min="0"
step="any"
value={item.unit_price === 0 ? "" : item.unit_price}
onChange={(e) =>
onItemChange?.(index, "unit_price", Number(e.target.value))
}
disabled={isDisabled}
className="text-right w-full"
/>
)}
</TableCell>
{/* 總退款金額 */}
<TableCell className="text-left">
{isReadOnly ? (
<span className="font-bold text-primary">{formatCurrency(item.total_amount)}</span>
) : (
<div className="space-y-1">
<Input
type="number"
min="0"
step="any"
value={item.total_amount || ""}
onChange={(e) =>
onItemChange?.(index, "total_amount", Number(e.target.value))
}
disabled={isDisabled}
className={`text-right w-full ${item.quantity_returned > 0 && (!item.total_amount || item.total_amount <= 0)
? "border-red-400 bg-red-50 focus-visible:ring-red-500"
: ""
}`}
/>
</div>
)}
</TableCell>
{/* 批號 */}
<TableCell className="text-left">
{isReadOnly ? (
<span className="text-gray-500">{item.batch_number}</span>
) : (
<Input
type="text"
value={item.batch_number || ""}
onChange={(e) =>
onItemChange?.(index, "batch_number", e.target.value)
}
disabled={isDisabled}
className="w-full"
placeholder="輸入批號或備註..."
/>
)}
</TableCell>
{/* 刪除按鈕 */}
{!isReadOnly && onRemoveItem && (
<TableCell className="text-center">
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="outline"
size="sm"
className="button-outlined-error"
title="移除項目"
>
<Trash2 className="h-4 w-4" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>退</AlertDialogTitle>
<AlertDialogDescription>
退
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction
onClick={() => onRemoveItem(index)}
className="bg-red-600 hover:bg-red-700"
>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</TableCell>
)}
</TableRow>
);
})
)}
</TableBody>
</Table>
</div>
);
}

View File

@@ -0,0 +1,26 @@
import { Badge } from "@/Components/ui/badge";
import { PurchaseReturnStatus, PURCHASE_RETURN_STATUS_CONFIG } from "@/types/purchase-return";
interface PurchaseReturnStatusBadgeProps {
status: PurchaseReturnStatus | string;
className?: string;
}
export default function PurchaseReturnStatusBadge({
status,
className = "",
}: PurchaseReturnStatusBadgeProps) {
const config = PURCHASE_RETURN_STATUS_CONFIG[status as PurchaseReturnStatus] || {
label: status,
color: "bg-gray-100 text-gray-800",
};
return (
<Badge
variant="secondary"
className={`${config.color} whitespace-nowrap min-w-[72px] justify-center ${className}`}
>
{config.label}
</Badge>
);
}

View File

@@ -0,0 +1,213 @@
import { useState, useMemo } from "react";
import { ArrowUpDown, ArrowUp, ArrowDown } from "lucide-react";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/Components/ui/table";
import { PurchaseReturnActions } from "./PurchaseReturnActions";
import PurchaseReturnStatusBadge from "./PurchaseReturnStatusBadge";
import CopyButton from "@/Components/shared/CopyButton";
import type { PurchaseReturn } from "@/types/purchase-return";
import { formatCurrency, formatDateTime } from "@/utils/format";
interface PurchaseReturnTableProps {
purchaseReturns: PurchaseReturn[];
}
type SortField = "code" | "warehouse_name" | "vendor_name" | "return_date" | "total_amount" | "status";
type SortDirection = "asc" | "desc" | null;
export default function PurchaseReturnTable({
purchaseReturns,
}: PurchaseReturnTableProps) {
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 sortedReturns = useMemo(() => {
if (!sortField || !sortDirection || !purchaseReturns) {
return purchaseReturns || [];
}
return [...purchaseReturns].sort((a, b) => {
let aValue: string | number;
let bValue: string | number;
switch (sortField) {
case "code":
aValue = a.code;
bValue = b.code;
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 "return_date":
aValue = a.return_date;
bValue = b.return_date;
break;
case "total_amount":
aValue = a.total_amount;
bValue = b.total_amount;
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);
}
});
}, [purchaseReturns, sortField, sortDirection]);
const SortIcon = ({ field }: { field: SortField }) => {
if (sortField !== field) {
return <ArrowUpDown className="h-4 w-4 text-muted-foreground ml-1" />;
}
if (sortDirection === "asc") {
return <ArrowUp className="h-4 w-4 text-primary ml-1" />;
}
if (sortDirection === "desc") {
return <ArrowDown className="h-4 w-4 text-primary ml-1" />;
}
return <ArrowUpDown className="h-4 w-4 text-muted-foreground ml-1" />;
};
return (
<div className="bg-white rounded-lg border shadow-sm overflow-hidden">
<div className="overflow-x-auto">
<Table>
<TableHeader className="bg-gray-50/50">
<TableRow>
<TableHead className="w-[50px] text-center">#</TableHead>
<TableHead className="w-[180px]">
<button
onClick={() => handleSort("code")}
className="flex items-center gap-1 hover:text-foreground transition-colors"
>
退
<SortIcon field="code" />
</button>
</TableHead>
<TableHead className="w-[180px]">
<button
onClick={() => handleSort("vendor_name")}
className="flex items-center gap-1 hover:text-foreground transition-colors"
>
<SortIcon field="vendor_name" />
</button>
</TableHead>
<TableHead className="w-[150px]">
<button
onClick={() => handleSort("return_date")}
className="flex items-center gap-1 hover:text-foreground transition-colors"
>
退
<SortIcon field="return_date" />
</button>
</TableHead>
<TableHead className="w-[140px] text-right">
<button
onClick={() => handleSort("total_amount")}
className="flex items-center justify-end gap-1 w-full 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 justify-center gap-1 w-full hover:text-foreground transition-colors"
>
<SortIcon field="status" />
</button>
</TableHead>
<TableHead className="text-center"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{!sortedReturns || sortedReturns.length === 0 ? (
<TableRow>
<TableCell colSpan={7} className="text-center text-muted-foreground py-12">
退
</TableCell>
</TableRow>
) : (
sortedReturns.map((item, index) => (
<TableRow key={item.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">{item.code}</span>
<CopyButton text={item.code} label="複製單號" />
</div>
</TableCell>
<TableCell>
<div className="space-y-0.5">
<span className="text-sm text-gray-700">{item.vendor?.name}</span>
<div className="text-xs text-gray-500">{item.user?.name}</div>
</div>
</TableCell>
<TableCell>
<span className="text-sm text-gray-500">{item.return_date}</span>
</TableCell>
<TableCell className="text-right">
<span className="font-semibold text-gray-900">{formatCurrency(item.total_amount)}</span>
</TableCell>
<TableCell className="text-center">
<PurchaseReturnStatusBadge status={item.status} />
</TableCell>
<TableCell className="text-center">
<PurchaseReturnActions
purchaseReturn={item}
/>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</div>
);
}