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({ - {/* 步驟二:品項明細 */} + {/* 發票資訊 */} +
+
+
2
+

發票資訊

+ (選填) +
+ +
+
+
+ + 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)}) +

+ )} +
+
+
+
+ + {/* 步驟三:品項明細 */}
-
2
+
3

採購商品明細