Files
star-erp/resources/js/Pages/PurchaseOrder/Create.tsx
sky121113 8cbf73681e
All checks were successful
Koori-ERP-Sync-Only / sync-update (push) Successful in 1m5s
修正bug
2025-12-31 17:48:36 +08:00

321 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 建立/編輯採購單頁面
*/
import { 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 {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/Components/ui/select";
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 {
validatePurchaseOrder,
filterValidItems,
calculateTotalAmount,
getTodayDate,
formatCurrency,
} from "@/utils/purchase-order";
import { STATUS_OPTIONS } from "@/constants/purchase-order";
import { toast } from "sonner";
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,
} = usePurchaseOrderForm({ order, suppliers });
const totalAmount = calculateTotalAmount(items);
const isValid = validatePurchaseOrder(String(supplierId), expectedDate, items);
const handleSave = () => {
if (!warehouseId) {
toast.error("請選擇入庫倉庫");
return;
}
if (!supplierId) {
toast.error("請選擇供應商");
return;
}
if (!expectedDate) {
toast.error("請選擇預計到貨日期");
return;
}
if (items.length === 0) {
toast.error("請至少新增一項採購商品");
return;
}
const validItems = filterValidItems(items);
if (validItems.length === 0) {
toast.error("請填寫有效的採購數量(必須大於 0");
return;
}
const data = {
vendor_id: supplierId,
warehouse_id: warehouseId,
expected_delivery_date: expectedDate,
remark: notes,
status: status,
items: validItems.map(item => ({
productId: item.productId,
quantity: item.quantity,
unitPrice: item.unitPrice,
})),
};
if (order) {
router.put(`/purchase-orders/${order.id}`, data, {
onSuccess: () => toast.success("採購單已更新"),
onError: (errors) => {
toast.error("更新失敗,請檢查輸入內容");
console.error(errors);
}
});
} else {
router.post("/purchase-orders", data, {
onSuccess: () => toast.success("採購單已成功建立"),
onError: (errors) => {
if (errors.error) {
toast.error(errors.error);
} else {
toast.error("建立失敗,請檢查輸入內容");
}
console.error(errors);
}
});
}
};
const hasSupplier = !!supplierId;
const canSave = isValid && !!warehouseId && items.length > 0;
return (
<AuthenticatedLayout>
<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>
<Select
value={String(warehouseId)}
onValueChange={setWarehouseId}
disabled={isOrderSent}
>
<SelectTrigger className="h-12 border-gray-200 focus:ring-primary/20">
<SelectValue placeholder="請選擇倉庫" />
</SelectTrigger>
<SelectContent>
{warehouses.map((w) => (
<SelectItem key={w.id} value={String(w.id)}>
{w.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-3">
<label className="text-sm font-bold text-gray-700"></label>
<Select
value={String(supplierId)}
onValueChange={setSupplierId}
disabled={isOrderSent}
>
<SelectTrigger className="h-12 border-gray-200">
<SelectValue placeholder="選擇供應商" />
</SelectTrigger>
<SelectContent>
{suppliers.map((s) => (
<SelectItem key={s.id} value={String(s.id)}>{s.name}</SelectItem>
))}
</SelectContent>
</Select>
</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>
<Select
value={status}
onValueChange={(v) => setStatus(v as any)}
>
<SelectTrigger className="h-12 border-gray-200">
<SelectValue placeholder="選擇狀態" />
</SelectTrigger>
<SelectContent>
{STATUS_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</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 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">2</div>
<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>
</div>
<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>
)}
<PurchaseOrderItemsTable
items={items}
supplier={selectedSupplier}
isReadOnly={isOrderSent}
isDisabled={!hasSupplier}
onRemoveItem={removeItem}
onItemChange={updateItem}
/>
{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>
</div>
)}
</div>
</div>
</div>
{/* 底部按鈕 */}
<div className="flex items-center justify-end gap-4 py-8 border-t border-gray-100 mt-8">
<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>
);
}