diff --git a/app/Http/Controllers/PurchaseOrderController.php b/app/Http/Controllers/PurchaseOrderController.php
index 26ea08a..18b87af 100644
--- a/app/Http/Controllers/PurchaseOrderController.php
+++ b/app/Http/Controllers/PurchaseOrderController.php
@@ -94,6 +94,9 @@ class PurchaseOrderController extends Controller
'warehouse_id' => 'required|exists:warehouses,id',
'expected_delivery_date' => 'nullable|date',
'remark' => 'nullable|string',
+ 'invoice_number' => ['nullable', 'string', 'max:11', 'regex:/^[A-Z]{2}-\d{8}$/'],
+ 'invoice_date' => 'nullable|date',
+ 'invoice_amount' => 'nullable|numeric|min:0',
'items' => 'required|array|min:1',
'items.*.productId' => 'required|exists:products,id',
'items.*.quantity' => 'required|numeric|min:0.01',
@@ -154,6 +157,9 @@ class PurchaseOrderController extends Controller
'tax_amount' => $taxAmount,
'grand_total' => $grandTotal,
'remark' => $validated['remark'],
+ 'invoice_number' => $validated['invoice_number'] ?? null,
+ 'invoice_date' => $validated['invoice_date'] ?? null,
+ 'invoice_amount' => $validated['invoice_amount'] ?? null,
]);
foreach ($validated['items'] as $item) {
@@ -310,6 +316,9 @@ class PurchaseOrderController extends Controller
'expected_delivery_date' => 'nullable|date',
'remark' => 'nullable|string',
'status' => 'required|string|in:draft,pending,processing,shipping,confirming,completed,cancelled',
+ 'invoice_number' => ['nullable', 'string', 'max:11', 'regex:/^[A-Z]{2}-\d{8}$/'],
+ 'invoice_date' => 'nullable|date',
+ 'invoice_amount' => 'nullable|numeric|min:0',
'items' => 'required|array|min:1',
'items.*.productId' => 'required|exists:products,id',
'items.*.quantity' => 'required|numeric|min:0.01',
@@ -338,6 +347,9 @@ class PurchaseOrderController extends Controller
'grand_total' => $grandTotal,
'remark' => $validated['remark'],
'status' => $validated['status'],
+ 'invoice_number' => $validated['invoice_number'] ?? null,
+ 'invoice_date' => $validated['invoice_date'] ?? null,
+ 'invoice_amount' => $validated['invoice_amount'] ?? null,
]);
// Sync items
diff --git a/app/Models/PurchaseOrder.php b/app/Models/PurchaseOrder.php
index 5cd4322..d6d7213 100644
--- a/app/Models/PurchaseOrder.php
+++ b/app/Models/PurchaseOrder.php
@@ -22,13 +22,18 @@ class PurchaseOrder extends Model
'tax_amount',
'grand_total',
'remark',
+ 'invoice_number',
+ 'invoice_date',
+ 'invoice_amount',
];
protected $casts = [
'expected_delivery_date' => 'date',
+ 'invoice_date' => 'date',
'total_amount' => 'decimal:2',
'tax_amount' => 'decimal:2',
'grand_total' => 'decimal:2',
+ 'invoice_amount' => 'decimal:2',
];
protected $appends = [
@@ -40,6 +45,9 @@ class PurchaseOrder extends Model
'createdBy',
'warehouse_name',
'createdAt',
+ 'invoiceNumber',
+ 'invoiceDate',
+ 'invoiceAmount',
];
public function getCreatedAtAttribute()
@@ -82,6 +90,22 @@ class PurchaseOrder extends Model
return $this->warehouse ? $this->warehouse->name : '';
}
+ public function getInvoiceNumberAttribute(): ?string
+ {
+ return $this->attributes['invoice_number'] ?? null;
+ }
+
+ public function getInvoiceDateAttribute(): ?string
+ {
+ $date = $this->attributes['invoice_date'] ?? null;
+ return $date ? \Illuminate\Support\Carbon::parse($date)->format('Y-m-d') : null;
+ }
+
+ public function getInvoiceAmountAttribute(): ?float
+ {
+ return isset($this->attributes['invoice_amount']) ? (float) $this->attributes['invoice_amount'] : null;
+ }
+
public function vendor(): BelongsTo
{
return $this->belongsTo(Vendor::class);
diff --git a/database/migrations/2026_01_09_095718_add_invoice_fields_to_purchase_orders_table.php b/database/migrations/2026_01_09_095718_add_invoice_fields_to_purchase_orders_table.php
new file mode 100644
index 0000000..bd51a9b
--- /dev/null
+++ b/database/migrations/2026_01_09_095718_add_invoice_fields_to_purchase_orders_table.php
@@ -0,0 +1,30 @@
+string('invoice_number', 11)->nullable()->comment('發票號碼 (格式: AB-12345678)');
+ $table->date('invoice_date')->nullable()->comment('發票日期');
+ $table->decimal('invoice_amount', 12, 2)->nullable()->comment('發票金額');
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::table('purchase_orders', function (Blueprint $table) {
+ $table->dropColumn(['invoice_number', 'invoice_date', 'invoice_amount']);
+ });
+ }
+};
diff --git a/resources/js/Components/Product/ProductDialog.tsx b/resources/js/Components/Product/ProductDialog.tsx
index ec40a2b..cf2e59c 100644
--- a/resources/js/Components/Product/ProductDialog.tsx
+++ b/resources/js/Components/Product/ProductDialog.tsx
@@ -11,13 +11,7 @@ import { Button } from "@/Components/ui/button";
import { Input } from "@/Components/ui/input";
import { Label } from "@/Components/ui/label";
import { Textarea } from "@/Components/ui/textarea";
-import {
- Select,
- SelectContent,
- SelectItem,
- SelectTrigger,
- SelectValue,
-} from "@/Components/ui/select";
+import { SearchableSelect } from "@/Components/ui/searchable-select";
import { useForm } from "@inertiajs/react";
import { toast } from "sonner";
import type { Product, Category } from "@/Pages/Product/Index";
@@ -123,21 +117,14 @@ export default function ProductDialog({
-
+ options={categories.map((c) => ({ label: c.name, value: c.id.toString() }))}
+ placeholder="選擇分類"
+ searchPlaceholder="搜尋分類..."
+ className={errors.category_id ? "border-red-500" : ""}
+ />
{errors.category_id &&
{errors.category_id}
}
@@ -188,42 +175,30 @@ export default function ProductDialog({
-
+ options={units.map((u) => ({ label: u.name, value: u.id.toString() }))}
+ placeholder="選擇單位"
+ searchPlaceholder="搜尋單位..."
+ className={errors.base_unit_id ? "border-red-500" : ""}
+ />
{errors.base_unit_id && {errors.base_unit_id}
}
-
+ options={[
+ { label: "無", value: "none" },
+ ...units.map((u) => ({ label: u.name, value: u.id.toString() }))
+ ]}
+ placeholder="無"
+ searchPlaceholder="搜尋單位..."
+ className={errors.large_unit_id ? "border-red-500" : ""}
+ />
{errors.large_unit_id &&
{errors.large_unit_id}
}
diff --git a/resources/js/Components/PurchaseOrder/PurchaseOrderItemsTable.tsx b/resources/js/Components/PurchaseOrder/PurchaseOrderItemsTable.tsx
index 8da4164..962767e 100644
--- a/resources/js/Components/PurchaseOrder/PurchaseOrderItemsTable.tsx
+++ b/resources/js/Components/PurchaseOrder/PurchaseOrderItemsTable.tsx
@@ -5,13 +5,7 @@
import { Trash2 } from "lucide-react";
import { Button } from "@/Components/ui/button";
import { Input } from "@/Components/ui/input";
-import {
- Select,
- SelectContent,
- SelectItem,
- SelectTrigger,
- SelectValue,
-} from "@/Components/ui/select";
+import { SearchableSelect } from "@/Components/ui/searchable-select";
import {
Table,
TableBody,
@@ -82,27 +76,18 @@ export function PurchaseOrderItemsTable({
{isReadOnly ? (
{item.productName}
) : (
-
+ options={supplier?.commonProducts.map((p) => ({ label: p.productName, value: p.productId })) || []}
+ placeholder="選擇商品"
+ searchPlaceholder="搜尋商品..."
+ emptyText="無可用商品"
+ className="w-full"
+ />
)}
@@ -128,21 +113,18 @@ export function PurchaseOrderItemsTable({
{/* 單位選擇 */}
{!isReadOnly && item.large_unit_id ? (
-
+ options={[
+ { label: item.base_unit_name || "個", value: "base" },
+ { label: item.large_unit_name || "", value: "large" }
+ ]}
+ className="w-24"
+ />
) : (
{item.selectedUnit === 'large' && item.large_unit_name
diff --git a/resources/js/Components/Warehouse/TransferOrderDialog.tsx b/resources/js/Components/Warehouse/TransferOrderDialog.tsx
index b38225a..3e09772 100644
--- a/resources/js/Components/Warehouse/TransferOrderDialog.tsx
+++ b/resources/js/Components/Warehouse/TransferOrderDialog.tsx
@@ -18,13 +18,7 @@ import {
import { Label } from "@/Components/ui/label";
import { Input } from "@/Components/ui/input";
import { Button } from "@/Components/ui/button";
-import {
- Select,
- SelectContent,
- SelectItem,
- SelectTrigger,
- SelectValue,
-} from "@/Components/ui/select";
+import { SearchableSelect } from "@/Components/ui/searchable-select";
import { Textarea } from "@/Components/ui/textarea";
import { toast } from "sonner";
import { Warehouse, TransferOrder, TransferOrderStatus } from "@/types/warehouse";
@@ -194,7 +188,7 @@ export default function TransferOrderDialog({
-
+ options={warehouses.map((warehouse) => ({ label: warehouse.name, value: warehouse.id }))}
+ placeholder="選擇來源倉庫"
+ searchPlaceholder="搜尋倉庫..."
+ />
-
+ options={warehouses
+ .filter((w) => w.id !== formData.sourceWarehouseId)
+ .map((warehouse) => ({ label: warehouse.name, value: warehouse.id }))}
+ placeholder="選擇目標倉庫"
+ searchPlaceholder="搜尋倉庫..."
+ />
@@ -253,7 +231,7 @@ export default function TransferOrderDialog({
-
+ options={availableProducts.map((product) => ({
+ label: `${product.productName} (庫存: ${product.availableQty} ${product.unit})`,
+ value: `${product.productId}|||${product.batchNumber}`,
+ }))}
+ placeholder="選擇商品與批號"
+ searchPlaceholder="搜尋商品..."
+ emptyText={formData.sourceWarehouseId ? "該倉庫無可用庫存" : "請先選擇來源倉庫"}
+ />
{/* 數量和日期 */}
@@ -329,22 +293,18 @@ export default function TransferOrderDialog({
{order && (
-
+ options={[
+ { label: "待處理", value: "待處理" },
+ { label: "處理中", value: "處理中" },
+ { label: "已完成", value: "已完成" },
+ { label: "已取消", value: "已取消" },
+ ]}
+ />
)}
diff --git a/resources/js/Components/ui/searchable-select.tsx b/resources/js/Components/ui/searchable-select.tsx
new file mode 100644
index 0000000..d9bf554
--- /dev/null
+++ b/resources/js/Components/ui/searchable-select.tsx
@@ -0,0 +1,139 @@
+import * as React from "react";
+import { Check, ChevronsUpDown } from "lucide-react";
+
+import { cn } from "@/lib/utils";
+import {
+ Command,
+ CommandEmpty,
+ CommandGroup,
+ CommandInput,
+ CommandItem,
+ CommandList,
+} from "@/Components/ui/command";
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "@/Components/ui/popover";
+
+interface Option {
+ label: string;
+ value: string;
+ sublabel?: string;
+ disabled?: boolean;
+}
+
+interface SearchableSelectProps {
+ value: string;
+ onValueChange: (value: string) => void;
+ options: Option[];
+ placeholder?: string;
+ searchPlaceholder?: string;
+ emptyText?: string;
+ disabled?: boolean;
+ className?: string;
+ /** 當選項數量超過此閾值時顯示搜尋框,預設為 10。若設為 0 則總是顯示。 */
+ searchThreshold?: number;
+ /** 強制控制是否顯示搜尋框。若設定此值,則忽略 searchThreshold */
+ showSearch?: boolean;
+}
+
+export function SearchableSelect({
+ value,
+ onValueChange,
+ options,
+ placeholder = "請選擇...",
+ searchPlaceholder = "搜尋...",
+ emptyText = "找不到符合的項目",
+ disabled = false,
+ className,
+ searchThreshold = 10,
+ showSearch,
+}: SearchableSelectProps) {
+ const [open, setOpen] = React.useState(false);
+
+ // 決定是否顯示搜尋框
+ const shouldShowSearch =
+ showSearch !== undefined ? showSearch : options.length > searchThreshold;
+
+ const selectedOption = options.find((option) => option.value === value);
+
+ return (
+
+
+
+
+
+
+ {shouldShowSearch && (
+
+ )}
+
+ {emptyText}
+
+ {options.map((option) => (
+ {
+ onValueChange(option.value);
+ setOpen(false);
+ }}
+ disabled={option.disabled}
+ className="cursor-pointer"
+ >
+
+
+ {option.label}
+ {option.sublabel && (
+
+ {option.sublabel}
+
+ )}
+
+
+ ))}
+
+
+
+
+
+ );
+}
diff --git a/resources/js/Pages/Product/Index.tsx b/resources/js/Pages/Product/Index.tsx
index 55b89a0..6aa9478 100644
--- a/resources/js/Pages/Product/Index.tsx
+++ b/resources/js/Pages/Product/Index.tsx
@@ -1,13 +1,7 @@
import { useState, useEffect, useCallback } from "react";
import { Button } from "@/Components/ui/button";
import { Input } from "@/Components/ui/input";
-import {
- Select,
- SelectContent,
- SelectItem,
- SelectTrigger,
- SelectValue,
-} from "@/Components/ui/select";
+import { SearchableSelect } from "@/Components/ui/searchable-select";
import { Plus, Search, X } from "lucide-react";
import ProductTable from "@/Components/Product/ProductTable";
import ProductDialog from "@/Components/Product/ProductDialog";
@@ -209,17 +203,16 @@ export default function ProductManagement({ products, categories, units, filters
{/* Type Filter */}
-
+ ({ label: cat.name, value: cat.id.toString() }))
+ ]}
+ placeholder="商品分類"
+ className="w-full md:w-[180px]"
+ />
{/* Add Button */}
@@ -260,17 +253,18 @@ export default function ProductManagement({ products, categories, units, filters
每頁顯示
-
+
筆
diff --git a/resources/js/Pages/PurchaseOrder/Create.tsx b/resources/js/Pages/PurchaseOrder/Create.tsx
index 99c37ba..0d22f0f 100644
--- a/resources/js/Pages/PurchaseOrder/Create.tsx
+++ b/resources/js/Pages/PurchaseOrder/Create.tsx
@@ -7,13 +7,7 @@ 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 { SearchableSelect } from "@/Components/ui/searchable-select";
import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout";
import { Head, Link, router } from "@inertiajs/react";
import { PurchaseOrderItemsTable } from "@/Components/PurchaseOrder/PurchaseOrderItemsTable";
@@ -58,6 +52,12 @@ export default function CreatePurchaseOrder({
updateItem,
status,
setStatus,
+ invoiceNumber,
+ invoiceDate,
+ invoiceAmount,
+ setInvoiceNumber,
+ setInvoiceDate,
+ setInvoiceAmount,
} = usePurchaseOrderForm({ order, suppliers });
@@ -110,6 +110,9 @@ export default function CreatePurchaseOrder({
expected_delivery_date: expectedDate,
remark: notes,
status: status,
+ invoice_number: invoiceNumber || null,
+ invoice_date: invoiceDate || null,
+ invoice_amount: invoiceAmount ? parseFloat(invoiceAmount) : null,
items: validItems.map(item => ({
productId: item.productId,
quantity: item.quantity,
@@ -191,40 +194,26 @@ export default function CreatePurchaseOrder({
-
+ options={warehouses.map((w) => ({ label: w.name, value: String(w.id) }))}
+ placeholder="請選擇倉庫"
+ searchPlaceholder="搜尋倉庫..."
+ />
-
+ options={suppliers.map((s) => ({ label: s.name, value: String(s.id) }))}
+ placeholder="選擇供應商"
+ searchPlaceholder="搜尋供應商..."
+ />
@@ -245,21 +234,12 @@ export default function CreatePurchaseOrder({
{order && (
-
+ options={STATUS_OPTIONS.map((opt) => ({ label: opt.label, value: opt.value }))}
+ placeholder="選擇狀態"
+ />
)}
@@ -276,11 +256,71 @@ export default function CreatePurchaseOrder({
- {/* 步驟二:品項明細 */}
+ {/* 發票資訊 */}
+
+
+
+
+
+
+
+
setInvoiceNumber(e.target.value)}
+ placeholder="AB-12345678"
+ maxLength={11}
+ className="h-12 border-gray-200"
+ />
+
格式:2 碼英文 + 分隔線 + 8 碼數字
+
+
+
+
+ setInvoiceDate(e.target.value)}
+ className="h-12 border-gray-200"
+ />
+
+
+
+
+
setInvoiceAmount(e.target.value)}
+ placeholder="0"
+ min="0"
+ step="0.01"
+ className="h-12 border-gray-200"
+ />
+ {invoiceAmount && totalAmount > 0 && parseFloat(invoiceAmount) !== totalAmount && (
+
+ ⚠️ 發票金額與採購總額不一致(總額:{formatCurrency(totalAmount)})
+
+ )}
+
+
+
+
+
+ {/* 步驟三:品項明細 */}