Files
star-erp/resources/js/Pages/PurchaseOrder/Create.tsx
sky121113 7367577f6a
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Successful in 59s
Koori-ERP-Deploy-System / deploy-production (push) Has been skipped
feat: 統一採購單與操作紀錄 UI、增強各模組操作紀錄功能
- 統一採購單篩選列與表單樣式 (移除舊元件、標準化 Input)
- 增強操作紀錄功能 (加入篩選、快照、詳細異動比對)
- 統一刪除確認視窗與按鈕樣式
- 修復庫存編輯頁面樣式
- 實作採購單品項異動紀錄
- 實作角色分配異動紀錄
- 擴充供應商與倉庫模組紀錄
2026-01-19 17:07:45 +08:00

445 lines
21 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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, ShoppingCart } from "lucide-react";
import { useEffect } from "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";
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";
import { getCreateBreadcrumbs, getEditBreadcrumbs } from "@/utils/breadcrumb";
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,
taxAmount,
setTaxAmount,
isTaxManual,
setIsTaxManual,
} = usePurchaseOrderForm({ order, suppliers });
const totalAmount = calculateTotalAmount(items);
// Auto-calculate tax if not manual
useEffect(() => {
if (!isTaxManual) {
const calculatedTax = Math.round(totalAmount * 0.05);
setTaxAmount(calculatedTax);
}
}, [totalAmount, isTaxManual]);
const handleSave = () => {
if (!warehouseId) {
toast.error("請選擇入庫倉庫");
return;
}
if (!supplierId) {
toast.error("請選擇供應商");
return;
}
if (!expectedDate) {
toast.error("請選擇預計到貨日期");
return;
}
if (items.length === 0) {
toast.error("請至少新增一項採購商品");
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;
}
const validItems = filterValidItems(items);
if (validItems.length === 0) {
toast.error("請確保所有商品都有填寫數量和單價");
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,
tax_amount: Number(taxAmount) || 0,
items: validItems.map(item => ({
productId: item.productId,
quantity: item.quantity,
unitPrice: item.unitPrice,
unitId: item.unitId,
subtotal: item.subtotal,
})),
};
if (order) {
router.put(`/purchase-orders/${order.id}`, data, {
onSuccess: () => toast.success("採購單已更新"),
onError: (errors) => {
// 顯示更詳細的錯誤訊息
if (errors.items) {
toast.error("商品資料有誤,請檢查數量和單價是否正確填寫");
} else if (errors.error) {
toast.error(errors.error);
} else {
toast.error("更新失敗,請檢查輸入內容");
}
console.error(errors);
}
});
} else {
router.post("/purchase-orders", data, {
onSuccess: () => toast.success("採購單已成功建立"),
onError: (errors) => {
if (errors.items) {
toast.error("商品資料有誤,請檢查數量和單價是否正確填寫");
} else if (errors.error) {
toast.error(errors.error);
} else {
toast.error("建立失敗,請檢查輸入內容");
}
console.error(errors);
}
});
}
};
const hasSupplier = !!supplierId;
return (
<AuthenticatedLayout breadcrumbs={order ? getEditBreadcrumbs("purchaseOrders") : getCreateBreadcrumbs("purchaseOrders")}>
<Head title={order ? "編輯採購單" : "建立採購單"} />
<div className="container mx-auto p-6 max-w-7xl">
{/* Header */}
<div className="mb-6">
<Link href="/purchase-orders">
<Button
variant="outline"
className="gap-2 button-outlined-primary mb-4"
>
<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">
<ShoppingCart className="h-6 w-6 text-primary-main" />
{order ? "編輯採購單" : "建立採購單"}
</h1>
<p className="text-gray-500 mt-1">
{order ? `修改採購單 ${order.poNumber} 的詳細資訊` : "填寫新採購單的資訊以開始流程"}
</p>
</div>
</div>
<div className="space-y-6">
{/* 步驟一:基本資訊 */}
<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-6 space-y-6">
<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
value={String(warehouseId)}
onValueChange={setWarehouseId}
disabled={isOrderSent}
options={warehouses.map((w) => ({ label: w.name, value: String(w.id) }))}
placeholder="請選擇倉庫"
searchPlaceholder="搜尋倉庫..."
/>
</div>
<div className="space-y-3">
<label className="text-sm font-bold text-gray-700"></label>
<SearchableSelect
value={String(supplierId)}
onValueChange={setSupplierId}
disabled={isOrderSent}
options={suppliers.map((s) => ({ label: s.name, value: String(s.id) }))}
placeholder="選擇供應商"
searchPlaceholder="搜尋供應商..."
/>
</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="block w-full"
/>
</div>
{order && (
<div className="space-y-3">
<label className="text-sm font-bold text-gray-700"></label>
<SearchableSelect
value={status}
onValueChange={(v) => setStatus(v as any)}
options={STATUS_OPTIONS.map((opt) => ({ label: opt.label, value: opt.value }))}
placeholder="選擇狀態"
/>
</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]"
/>
</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-6 space-y-6">
<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="block w-full"
/>
<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="block w-full"
/>
</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="block w-full"
/>
{invoiceAmount && totalAmount > 0 && parseFloat(invoiceAmount) !== totalAmount && (
<p className="text-xs text-amber-600">
{formatCurrency(totalAmount)}
</p>
)}
</div>
</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">3</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-6">
{!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-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(totalAmount)}</span>
</div>
<div className="flex justify-between items-center w-full gap-4">
<div className="flex items-center gap-2">
<span className="text-sm text-gray-500 font-medium"> (5%)</span>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 text-gray-400 hover:text-primary"
title="重設為自動計算 (5%)"
onClick={() => {
const autoTax = Math.round(totalAmount * 0.05);
setTaxAmount(autoTax);
setIsTaxManual(false);
toast.success("已重設為自動計算 (5%)");
}}
>
</Button>
</div>
<div className="relative w-32">
<Input
type="number"
value={taxAmount}
onChange={(e) => {
setTaxAmount(e.target.value);
setIsTaxManual(true);
}}
className="text-right h-9 bg-white"
placeholder="0"
/>
</div>
</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(totalAmount + (Number(taxAmount) || 0))}
</span>
</div>
</div>
</div>
)}
</div>
</div>
</div>
{/* 底部按鈕 */}
<div className="flex items-center justify-end gap-4 py-6 border-t border-gray-100 mt-6">
<Link href="/purchase-orders">
<Button variant="ghost" className="h-11 px-6 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>
);
}