Files
star-erp/resources/js/Pages/PurchaseOrder/Create.tsx

385 lines
18 KiB
TypeScript
Raw Normal View History

2025-12-30 15:03:19 +08:00
/**
* /
*/
import { ArrowLeft, Plus, Info } from "lucide-react";
import { Button } from "@/Components/ui/button";
import { Input } from "@/Components/ui/input";
import { Textarea } from "@/Components/ui/textarea";
import { Alert, AlertDescription } from "@/Components/ui/alert";
import { SearchableSelect } from "@/Components/ui/searchable-select";
2025-12-30 15:03:19 +08:00
import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout";
import { Head, Link, router } from "@inertiajs/react";
import { PurchaseOrderItemsTable } from "@/Components/PurchaseOrder/PurchaseOrderItemsTable";
import type { PurchaseOrder, Supplier } from "@/types/purchase-order";
import type { Warehouse } from "@/types/requester";
import { usePurchaseOrderForm } from "@/hooks/usePurchaseOrderForm";
import {
filterValidItems,
calculateTotalAmount,
getTodayDate,
formatCurrency,
} from "@/utils/purchase-order";
import { STATUS_OPTIONS } from "@/constants/purchase-order";
import { toast } from "sonner";
2026-01-07 13:06:49 +08:00
import { getCreateBreadcrumbs, getEditBreadcrumbs } from "@/utils/breadcrumb";
2025-12-30 15:03:19 +08:00
interface Props {
order?: PurchaseOrder;
suppliers: Supplier[];
warehouses: Warehouse[];
}
export default function CreatePurchaseOrder({
order,
suppliers,
warehouses,
}: Props) {
const {
supplierId,
expectedDate,
items,
notes,
selectedSupplier,
isOrderSent,
warehouseId,
setSupplierId,
setExpectedDate,
setNotes,
setWarehouseId,
addItem,
removeItem,
updateItem,
status,
setStatus,
invoiceNumber,
invoiceDate,
invoiceAmount,
setInvoiceNumber,
setInvoiceDate,
setInvoiceAmount,
2025-12-30 15:03:19 +08:00
} = usePurchaseOrderForm({ order, suppliers });
2025-12-30 15:03:19 +08:00
const totalAmount = calculateTotalAmount(items);
const handleSave = () => {
2025-12-31 17:48:36 +08:00
if (!warehouseId) {
toast.error("請選擇入庫倉庫");
return;
}
if (!supplierId) {
toast.error("請選擇供應商");
return;
}
if (!expectedDate) {
toast.error("請選擇預計到貨日期");
return;
}
if (items.length === 0) {
toast.error("請至少新增一項採購商品");
2025-12-30 15:03:19 +08:00
return;
}
// 檢查是否有數量大於 0 的項目
const itemsWithQuantity = items.filter(item => item.quantity > 0);
if (itemsWithQuantity.length === 0) {
toast.error("請填寫有效的採購數量(必須大於 0");
return;
}
// 檢查有數量的項目是否都有填寫單價
const itemsWithoutPrice = itemsWithQuantity.filter(item => !item.unitPrice || item.unitPrice <= 0);
if (itemsWithoutPrice.length > 0) {
toast.error("請填寫所有商品的預估單價(必須大於 0");
return;
}
2025-12-30 15:03:19 +08:00
const validItems = filterValidItems(items);
if (validItems.length === 0) {
toast.error("請確保所有商品都有填寫數量和單價");
2025-12-30 15:03:19 +08:00
return;
}
const data = {
vendor_id: supplierId,
warehouse_id: warehouseId,
expected_delivery_date: expectedDate,
remark: notes,
status: status,
invoice_number: invoiceNumber || null,
invoice_date: invoiceDate || null,
invoice_amount: invoiceAmount ? parseFloat(invoiceAmount) : null,
2025-12-30 15:03:19 +08:00
items: validItems.map(item => ({
productId: item.productId,
quantity: item.quantity,
unitPrice: item.unitPrice,
2026-01-08 16:52:03 +08:00
unitId: item.unitId,
2026-01-08 17:51:06 +08:00
subtotal: item.subtotal,
2025-12-30 15:03:19 +08:00
})),
};
if (order) {
router.put(`/purchase-orders/${order.id}`, data, {
2025-12-31 17:48:36 +08:00
onSuccess: () => toast.success("採購單已更新"),
onError: (errors) => {
// 顯示更詳細的錯誤訊息
if (errors.items) {
toast.error("商品資料有誤,請檢查數量和單價是否正確填寫");
} else if (errors.error) {
toast.error(errors.error);
} else {
toast.error("更新失敗,請檢查輸入內容");
}
2025-12-31 17:48:36 +08:00
console.error(errors);
}
2025-12-30 15:03:19 +08:00
});
} else {
router.post("/purchase-orders", data, {
2025-12-31 17:48:36 +08:00
onSuccess: () => toast.success("採購單已成功建立"),
onError: (errors) => {
if (errors.items) {
toast.error("商品資料有誤,請檢查數量和單價是否正確填寫");
} else if (errors.error) {
2025-12-31 17:48:36 +08:00
toast.error(errors.error);
} else {
toast.error("建立失敗,請檢查輸入內容");
2025-12-31 17:48:36 +08:00
}
console.error(errors);
}
2025-12-30 15:03:19 +08:00
});
}
};
const hasSupplier = !!supplierId;
return (
2026-01-07 13:06:49 +08:00
<AuthenticatedLayout breadcrumbs={order ? getEditBreadcrumbs("purchaseOrders") : getCreateBreadcrumbs("purchaseOrders")}>
2025-12-30 15:03:19 +08:00
<Head title={order ? "編輯採購單" : "建立採購單"} />
<div className="container mx-auto p-6 max-w-5xl">
{/* Header */}
<div className="mb-8">
<Link href="/purchase-orders">
<Button
variant="outline"
className="gap-2 button-outlined-primary mb-6"
>
<ArrowLeft className="h-4 w-4" />
</Button>
</Link>
<div className="mb-6">
<h1 className="mb-2">{order ? "編輯採購單" : "建立採購單"}</h1>
<p className="text-gray-600">
{order ? `修改採購單 ${order.poNumber} 的詳細資訊` : "填寫新採購單的資訊以開始流程"}
</p>
</div>
</div>
<div className="space-y-8">
{/* 步驟一:基本資訊 */}
<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 bg-primary text-white flex items-center justify-center font-bold">1</div>
<h2 className="text-lg font-bold"></h2>
</div>
<div className="p-8 space-y-8">
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
<div className="space-y-3">
<label className="text-sm font-bold text-gray-700">
</label>
<SearchableSelect
2025-12-30 15:03:19 +08:00
value={String(warehouseId)}
onValueChange={setWarehouseId}
disabled={isOrderSent}
options={warehouses.map((w) => ({ label: w.name, value: String(w.id) }))}
placeholder="請選擇倉庫"
searchPlaceholder="搜尋倉庫..."
/>
2025-12-30 15:03:19 +08:00
</div>
<div className="space-y-3">
<label className="text-sm font-bold text-gray-700"></label>
<SearchableSelect
2025-12-30 15:03:19 +08:00
value={String(supplierId)}
onValueChange={setSupplierId}
disabled={isOrderSent}
options={suppliers.map((s) => ({ label: s.name, value: String(s.id) }))}
placeholder="選擇供應商"
searchPlaceholder="搜尋供應商..."
/>
2025-12-30 15:03:19 +08:00
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
<div className="space-y-3">
<label className="text-sm font-bold text-gray-700">
</label>
<Input
type="date"
value={expectedDate || ""}
onChange={(e) => setExpectedDate(e.target.value)}
min={getTodayDate()}
className="h-12 border-gray-200"
/>
</div>
{order && (
<div className="space-y-3">
<label className="text-sm font-bold text-gray-700"></label>
<SearchableSelect
2025-12-30 15:03:19 +08:00
value={status}
onValueChange={(v) => setStatus(v as any)}
options={STATUS_OPTIONS.map((opt) => ({ label: opt.label, value: opt.value }))}
placeholder="選擇狀態"
/>
2025-12-30 15:03:19 +08:00
</div>
)}
</div>
<div className="space-y-3">
<label className="text-sm font-bold text-gray-700"></label>
<Textarea
value={notes || ""}
onChange={(e) => setNotes(e.target.value)}
placeholder="備註這筆採購單的特殊需求..."
className="min-h-[100px] border-gray-200"
/>
</div>
</div>
</div>
{/* 發票資訊 */}
<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 bg-primary text-white flex items-center justify-center font-bold">2</div>
<h2 className="text-lg font-bold"></h2>
<span className="text-sm text-gray-500"></span>
</div>
<div className="p-8 space-y-8">
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
<div className="space-y-3">
<label className="text-sm font-bold text-gray-700">
</label>
<Input
type="text"
value={invoiceNumber}
onChange={(e) => setInvoiceNumber(e.target.value)}
placeholder="AB-12345678"
maxLength={11}
className="h-12 border-gray-200"
/>
<p className="text-xs text-gray-500">2 + + 8 </p>
</div>
<div className="space-y-3">
<label className="text-sm font-bold text-gray-700">
</label>
<Input
type="date"
value={invoiceDate}
onChange={(e) => setInvoiceDate(e.target.value)}
className="h-12 border-gray-200"
/>
</div>
<div className="space-y-3">
<label className="text-sm font-bold text-gray-700">
</label>
<Input
type="number"
value={invoiceAmount}
onChange={(e) => setInvoiceAmount(e.target.value)}
placeholder="0"
min="0"
step="0.01"
className="h-12 border-gray-200"
/>
{invoiceAmount && totalAmount > 0 && parseFloat(invoiceAmount) !== totalAmount && (
<p className="text-xs text-amber-600">
{formatCurrency(totalAmount)}
</p>
)}
</div>
</div>
</div>
</div>
{/* 步驟三:品項明細 */}
2025-12-30 17:05:19 +08:00
<div className={`bg-white rounded-lg border shadow-sm overflow-hidden transition-all duration-300 ${!hasSupplier ? 'opacity-60 saturate-50' : ''}`}>
<div className="p-6 bg-gray-50/50 border-b flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-full bg-primary text-white flex items-center justify-center font-bold">3</div>
2025-12-30 17:05:19 +08:00
<h2 className="text-lg font-bold"></h2>
</div>
<Button
onClick={addItem}
disabled={!hasSupplier || isOrderSent}
className="button-filled-primary h-10 gap-2"
>
<Plus className="h-4 w-4" />
</Button>
2025-12-30 15:03:19 +08:00
</div>
2025-12-30 17:05:19 +08:00
<div className="p-8">
{!hasSupplier && (
<Alert className="mb-6 bg-amber-50 border-amber-200 text-amber-800">
<Info className="h-4 w-4 text-amber-600" />
<AlertDescription>
</AlertDescription>
</Alert>
)}
2025-12-30 15:03:19 +08:00
2025-12-30 17:05:19 +08:00
<PurchaseOrderItemsTable
items={items}
supplier={selectedSupplier}
isReadOnly={isOrderSent}
isDisabled={!hasSupplier}
onRemoveItem={removeItem}
onItemChange={updateItem}
/>
2025-12-30 15:03:19 +08:00
2025-12-30 17:05:19 +08:00
{hasSupplier && items.length > 0 && (
<div className="mt-8 flex justify-end">
<div className="bg-primary/5 px-8 py-5 rounded-xl border border-primary/10 inline-flex flex-col items-end min-w-[240px]">
<span className="text-sm text-gray-500 font-medium mb-1"></span>
<span className="text-3xl font-black text-primary">{formatCurrency(totalAmount)}</span>
</div>
2025-12-30 15:03:19 +08:00
</div>
2025-12-30 17:05:19 +08:00
)}
</div>
2025-12-30 15:03:19 +08:00
</div>
</div>
{/* 底部按鈕 */}
2025-12-30 17:05:19 +08:00
<div className="flex items-center justify-end gap-4 py-8 border-t border-gray-100 mt-8">
2025-12-30 15:03:19 +08:00
<Link href="/purchase-orders">
<Button variant="ghost" className="h-12 px-8 text-gray-500 hover:text-gray-700">
</Button>
</Link>
<Button
size="lg"
className="bg-primary hover:bg-primary/90 text-white px-12 h-14 rounded-xl shadow-lg shadow-primary/20 text-lg font-bold transition-all hover:scale-[1.02] active:scale-[0.98]"
onClick={handleSave}
>
{order ? "更新採購單" : "確認發布採購單"}
</Button>
</div>
</div>
</AuthenticatedLayout>
);
}