Compare commits
3 Commits
eca2f38395
...
0b60dab208
| Author | SHA1 | Date | |
|---|---|---|---|
| 0b60dab208 | |||
| 7848976a06 | |||
| 48115082e5 |
@@ -72,3 +72,6 @@ Routes: kebab-case (小寫橫線分隔)
|
|||||||
生成 React 組件時,必須符合專案現有的 Tailwind CSS 配置。
|
生成 React 組件時,必須符合專案現有的 Tailwind CSS 配置。
|
||||||
|
|
||||||
必須考慮 ERP 邏輯(例如:權限判斷、操作日誌、資料完整性)。
|
必須考慮 ERP 邏輯(例如:權限判斷、操作日誌、資料完整性)。
|
||||||
|
|
||||||
|
7.運行機制
|
||||||
|
因為是運行在docker上 所以要執行php的話 要執行docker exce
|
||||||
@@ -10,6 +10,7 @@ class InventoryController extends Controller
|
|||||||
{
|
{
|
||||||
$warehouse->load([
|
$warehouse->load([
|
||||||
'inventories.product.category',
|
'inventories.product.category',
|
||||||
|
'inventories.product.baseUnit',
|
||||||
'inventories.lastIncomingTransaction',
|
'inventories.lastIncomingTransaction',
|
||||||
'inventories.lastOutgoingTransaction'
|
'inventories.lastOutgoingTransaction'
|
||||||
]);
|
]);
|
||||||
@@ -20,7 +21,7 @@ class InventoryController extends Controller
|
|||||||
return [
|
return [
|
||||||
'id' => (string) $product->id, // Frontend expects string
|
'id' => (string) $product->id, // Frontend expects string
|
||||||
'name' => $product->name,
|
'name' => $product->name,
|
||||||
'type' => $product->category ? $product->category->name : '其他', // 暫時用 Category Name 當 Type
|
'type' => $product->category?->name ?? '其他', // 暫時用 Category Name 當 Type
|
||||||
];
|
];
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -32,9 +33,9 @@ class InventoryController extends Controller
|
|||||||
'id' => (string) $inv->id,
|
'id' => (string) $inv->id,
|
||||||
'warehouseId' => (string) $inv->warehouse_id,
|
'warehouseId' => (string) $inv->warehouse_id,
|
||||||
'productId' => (string) $inv->product_id,
|
'productId' => (string) $inv->product_id,
|
||||||
'productName' => $inv->product->name,
|
'productName' => $inv->product?->name ?? '未知商品',
|
||||||
'productCode' => $inv->product->code ?? 'N/A',
|
'productCode' => $inv->product?->code ?? 'N/A',
|
||||||
'unit' => $inv->product->base_unit ?? '個',
|
'unit' => $inv->product?->baseUnit?->name ?? '個',
|
||||||
'quantity' => (float) $inv->quantity,
|
'quantity' => (float) $inv->quantity,
|
||||||
'safetyStock' => $inv->safety_stock !== null ? (float) $inv->safety_stock : null,
|
'safetyStock' => $inv->safety_stock !== null ? (float) $inv->safety_stock : null,
|
||||||
'status' => '正常', // 前端會根據 quantity 與 safetyStock 重算,但後端亦可提供基礎狀態
|
'status' => '正常', // 前端會根據 quantity 與 safetyStock 重算,但後端亦可提供基礎狀態
|
||||||
@@ -53,8 +54,8 @@ class InventoryController extends Controller
|
|||||||
'id' => 'ss-' . $inv->id,
|
'id' => 'ss-' . $inv->id,
|
||||||
'warehouseId' => (string) $inv->warehouse_id,
|
'warehouseId' => (string) $inv->warehouse_id,
|
||||||
'productId' => (string) $inv->product_id,
|
'productId' => (string) $inv->product_id,
|
||||||
'productName' => $inv->product->name,
|
'productName' => $inv->product?->name ?? '未知商品',
|
||||||
'productType' => $inv->product->category ? $inv->product->category->name : '其他',
|
'productType' => $inv->product?->category?->name ?? '其他',
|
||||||
'safetyStock' => (float) $inv->safety_stock,
|
'safetyStock' => (float) $inv->safety_stock,
|
||||||
'createdAt' => $inv->created_at->toIso8601String(),
|
'createdAt' => $inv->created_at->toIso8601String(),
|
||||||
'updatedAt' => $inv->updated_at->toIso8601String(),
|
'updatedAt' => $inv->updated_at->toIso8601String(),
|
||||||
@@ -72,11 +73,13 @@ class InventoryController extends Controller
|
|||||||
public function create(\App\Models\Warehouse $warehouse)
|
public function create(\App\Models\Warehouse $warehouse)
|
||||||
{
|
{
|
||||||
// 取得所有商品供前端選單使用
|
// 取得所有商品供前端選單使用
|
||||||
$products = \App\Models\Product::select('id', 'name', 'base_unit')->get()->map(function ($product) {
|
$products = \App\Models\Product::with(['baseUnit', 'largeUnit'])->select('id', 'name', 'base_unit_id', 'large_unit_id', 'conversion_rate')->get()->map(function ($product) {
|
||||||
return [
|
return [
|
||||||
'id' => (string) $product->id,
|
'id' => (string) $product->id,
|
||||||
'name' => $product->name,
|
'name' => $product->name,
|
||||||
'unit' => $product->base_unit,
|
'baseUnit' => $product->baseUnit?->name ?? '個',
|
||||||
|
'largeUnit' => $product->largeUnit?->name, // 可能為 null
|
||||||
|
'conversionRate' => (float) $product->conversion_rate,
|
||||||
];
|
];
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -145,7 +148,7 @@ class InventoryController extends Controller
|
|||||||
'id' => (string) $inventory->id,
|
'id' => (string) $inventory->id,
|
||||||
'warehouseId' => (string) $inventory->warehouse_id,
|
'warehouseId' => (string) $inventory->warehouse_id,
|
||||||
'productId' => (string) $inventory->product_id,
|
'productId' => (string) $inventory->product_id,
|
||||||
'productName' => $inventory->product->name,
|
'productName' => $inventory->product?->name ?? '未知商品',
|
||||||
'quantity' => (float) $inventory->quantity,
|
'quantity' => (float) $inventory->quantity,
|
||||||
'batchNumber' => 'BATCH-' . $inventory->id, // Mock
|
'batchNumber' => 'BATCH-' . $inventory->id, // Mock
|
||||||
'expiryDate' => '2099-12-31', // Mock
|
'expiryDate' => '2099-12-31', // Mock
|
||||||
@@ -315,8 +318,8 @@ class InventoryController extends Controller
|
|||||||
'warehouse' => $warehouse,
|
'warehouse' => $warehouse,
|
||||||
'inventory' => [
|
'inventory' => [
|
||||||
'id' => (string) $inventory->id,
|
'id' => (string) $inventory->id,
|
||||||
'productName' => $inventory->product->name,
|
'productName' => $inventory->product?->name ?? '未知商品',
|
||||||
'productCode' => $inventory->product->code,
|
'productCode' => $inventory->product?->code ?? 'N/A',
|
||||||
'quantity' => (float) $inventory->quantity,
|
'quantity' => (float) $inventory->quantity,
|
||||||
],
|
],
|
||||||
'transactions' => $transactions
|
'transactions' => $transactions
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
namespace App\Http\Controllers;
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
use App\Models\Product;
|
use App\Models\Product;
|
||||||
|
use App\Models\Unit;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Inertia\Inertia;
|
use Inertia\Inertia;
|
||||||
use Inertia\Response;
|
use Inertia\Response;
|
||||||
@@ -14,7 +15,7 @@ class ProductController extends Controller
|
|||||||
*/
|
*/
|
||||||
public function index(Request $request): Response
|
public function index(Request $request): Response
|
||||||
{
|
{
|
||||||
$query = Product::with('category');
|
$query = Product::with(['category', 'baseUnit', 'largeUnit', 'purchaseUnit']);
|
||||||
|
|
||||||
if ($request->filled('search')) {
|
if ($request->filled('search')) {
|
||||||
$search = $request->search;
|
$search = $request->search;
|
||||||
@@ -38,7 +39,7 @@ class ProductController extends Controller
|
|||||||
$sortDirection = $request->input('sort_direction', 'desc');
|
$sortDirection = $request->input('sort_direction', 'desc');
|
||||||
|
|
||||||
// Define allowed sort fields to prevent SQL injection
|
// Define allowed sort fields to prevent SQL injection
|
||||||
$allowedSorts = ['id', 'code', 'name', 'category_id', 'base_unit', 'conversion_rate'];
|
$allowedSorts = ['id', 'code', 'name', 'category_id', 'base_unit_id', 'conversion_rate'];
|
||||||
if (!in_array($sortField, $allowedSorts)) {
|
if (!in_array($sortField, $allowedSorts)) {
|
||||||
$sortField = 'id';
|
$sortField = 'id';
|
||||||
}
|
}
|
||||||
@@ -63,6 +64,7 @@ class ProductController extends Controller
|
|||||||
return Inertia::render('Product/Index', [
|
return Inertia::render('Product/Index', [
|
||||||
'products' => $products,
|
'products' => $products,
|
||||||
'categories' => $categories,
|
'categories' => $categories,
|
||||||
|
'units' => Unit::all(),
|
||||||
'filters' => $request->only(['search', 'category_id', 'per_page', 'sort_field', 'sort_direction']),
|
'filters' => $request->only(['search', 'category_id', 'per_page', 'sort_field', 'sort_direction']),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
@@ -77,15 +79,17 @@ class ProductController extends Controller
|
|||||||
'category_id' => 'required|exists:categories,id',
|
'category_id' => 'required|exists:categories,id',
|
||||||
'brand' => 'nullable|string|max:255',
|
'brand' => 'nullable|string|max:255',
|
||||||
'specification' => 'nullable|string',
|
'specification' => 'nullable|string',
|
||||||
'base_unit' => 'required|string|max:50',
|
|
||||||
'large_unit' => 'nullable|string|max:50',
|
'base_unit_id' => 'required|exists:units,id',
|
||||||
'conversion_rate' => 'required_with:large_unit|nullable|numeric|min:0.0001',
|
'large_unit_id' => 'nullable|exists:units,id',
|
||||||
'purchase_unit' => 'nullable|string|max:50',
|
'conversion_rate' => 'required_with:large_unit_id|nullable|numeric|min:0.0001',
|
||||||
|
'purchase_unit_id' => 'nullable|exists:units,id',
|
||||||
], [
|
], [
|
||||||
'name.required' => '商品名稱為必填',
|
'name.required' => '商品名稱為必填',
|
||||||
'category_id.required' => '請選擇分類',
|
'category_id.required' => '請選擇分類',
|
||||||
'category_id.exists' => '所選分類不存在',
|
'category_id.exists' => '所選分類不存在',
|
||||||
'base_unit.required' => '基本庫存單位為必填',
|
'base_unit_id.required' => '基本庫存單位為必填',
|
||||||
|
'base_unit_id.exists' => '所選基本單位不存在',
|
||||||
'conversion_rate.required_with' => '填寫大單位時,換算率為必填',
|
'conversion_rate.required_with' => '填寫大單位時,換算率為必填',
|
||||||
'conversion_rate.numeric' => '換算率必須為數字',
|
'conversion_rate.numeric' => '換算率必須為數字',
|
||||||
'conversion_rate.min' => '換算率最小為 0.0001',
|
'conversion_rate.min' => '換算率最小為 0.0001',
|
||||||
@@ -109,14 +113,24 @@ class ProductController extends Controller
|
|||||||
*/
|
*/
|
||||||
public function update(Request $request, Product $product)
|
public function update(Request $request, Product $product)
|
||||||
{
|
{
|
||||||
$validated = $request->validate([
|
$validated = $request->validate([
|
||||||
'name' => 'required|string|max:255',
|
'name' => 'required|string|max:255',
|
||||||
'category_id' => 'required|exists:categories,id',
|
'category_id' => 'required|exists:categories,id',
|
||||||
'brand' => 'nullable|string|max:255',
|
'brand' => 'nullable|string|max:255',
|
||||||
'specification' => 'nullable|string',
|
'specification' => 'nullable|string',
|
||||||
'base_unit' => 'required|string|max:50',
|
'base_unit_id' => 'required|exists:units,id',
|
||||||
'large_unit' => 'nullable|string|max:50',
|
'large_unit_id' => 'nullable|exists:units,id',
|
||||||
'conversion_rate' => 'required_with:large_unit|nullable|numeric|min:0.0001',
|
'conversion_rate' => 'required_with:large_unit_id|nullable|numeric|min:0.0001',
|
||||||
|
'purchase_unit_id' => 'nullable|exists:units,id',
|
||||||
|
], [
|
||||||
|
'name.required' => '商品名稱為必填',
|
||||||
|
'category_id.required' => '請選擇分類',
|
||||||
|
'category_id.exists' => '所選分類不存在',
|
||||||
|
'base_unit_id.required' => '基本庫存單位為必填',
|
||||||
|
'base_unit_id.exists' => '所選基本單位不存在',
|
||||||
|
'conversion_rate.required_with' => '填寫大單位時,換算率為必填',
|
||||||
|
'conversion_rate.numeric' => '換算率必須為數字',
|
||||||
|
'conversion_rate.min' => '換算率最小為 0.0001',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$product->update($validated);
|
$product->update($validated);
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ class PurchaseOrderController extends Controller
|
|||||||
|
|
||||||
public function create()
|
public function create()
|
||||||
{
|
{
|
||||||
$vendors = Vendor::with('products')->get()->map(function ($vendor) {
|
$vendors = Vendor::with(['products.baseUnit', 'products.largeUnit', 'products.purchaseUnit'])->get()->map(function ($vendor) {
|
||||||
return [
|
return [
|
||||||
'id' => (string) $vendor->id,
|
'id' => (string) $vendor->id,
|
||||||
'name' => $vendor->name,
|
'name' => $vendor->name,
|
||||||
@@ -62,9 +62,11 @@ class PurchaseOrderController extends Controller
|
|||||||
return [
|
return [
|
||||||
'productId' => (string) $product->id,
|
'productId' => (string) $product->id,
|
||||||
'productName' => $product->name,
|
'productName' => $product->name,
|
||||||
'unit' => $product->purchase_unit ?: ($product->large_unit ?: $product->base_unit), // 優先使用採購單位 > 大單位 > 基本單位
|
'base_unit_id' => $product->base_unit_id,
|
||||||
'base_unit' => $product->base_unit,
|
'base_unit_name' => $product->baseUnit?->name,
|
||||||
'purchase_unit' => $product->purchase_unit ?: $product->large_unit, // 若無採購單位,預設為大單位
|
'large_unit_id' => $product->large_unit_id,
|
||||||
|
'large_unit_name' => $product->largeUnit?->name,
|
||||||
|
'purchase_unit_id' => $product->purchase_unit_id,
|
||||||
'conversion_rate' => (float) $product->conversion_rate,
|
'conversion_rate' => (float) $product->conversion_rate,
|
||||||
'lastPrice' => (float) ($product->pivot->last_price ?? 0),
|
'lastPrice' => (float) ($product->pivot->last_price ?? 0),
|
||||||
];
|
];
|
||||||
@@ -96,6 +98,7 @@ class PurchaseOrderController extends Controller
|
|||||||
'items.*.productId' => 'required|exists:products,id',
|
'items.*.productId' => 'required|exists:products,id',
|
||||||
'items.*.quantity' => 'required|numeric|min:0.01',
|
'items.*.quantity' => 'required|numeric|min:0.01',
|
||||||
'items.*.unitPrice' => 'required|numeric|min:0',
|
'items.*.unitPrice' => 'required|numeric|min:0',
|
||||||
|
'items.*.unitId' => 'nullable|exists:units,id', // 驗證單位ID
|
||||||
]);
|
]);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -157,6 +160,7 @@ class PurchaseOrderController extends Controller
|
|||||||
$order->items()->create([
|
$order->items()->create([
|
||||||
'product_id' => $item['productId'],
|
'product_id' => $item['productId'],
|
||||||
'quantity' => $item['quantity'],
|
'quantity' => $item['quantity'],
|
||||||
|
'unit_id' => $item['unitId'] ?? null, // 儲存單位ID
|
||||||
'unit_price' => $item['unitPrice'],
|
'unit_price' => $item['unitPrice'],
|
||||||
'subtotal' => $item['quantity'] * $item['unitPrice'],
|
'subtotal' => $item['quantity'] * $item['unitPrice'],
|
||||||
]);
|
]);
|
||||||
@@ -174,20 +178,39 @@ class PurchaseOrderController extends Controller
|
|||||||
|
|
||||||
public function show($id)
|
public function show($id)
|
||||||
{
|
{
|
||||||
$order = PurchaseOrder::with(['vendor', 'warehouse', 'user', 'items.product'])->findOrFail($id);
|
$order = PurchaseOrder::with(['vendor', 'warehouse', 'user', 'items.product.baseUnit', 'items.product.largeUnit'])->findOrFail($id);
|
||||||
|
|
||||||
// Transform items to include product details needed for frontend calculation
|
$order->items->transform(function ($item) use ($order) {
|
||||||
$order->items->transform(function ($item) {
|
|
||||||
$product = $item->product;
|
$product = $item->product;
|
||||||
if ($product) {
|
if ($product) {
|
||||||
// 手動附加 productName 和 unit (因為已從 $appends 移除)
|
// 手動附加所有必要的屬性
|
||||||
|
$item->productId = (string) $product->id;
|
||||||
$item->productName = $product->name;
|
$item->productName = $product->name;
|
||||||
$item->productId = $product->id;
|
$item->base_unit_id = $product->base_unit_id;
|
||||||
$item->base_unit = $product->base_unit;
|
$item->base_unit_name = $product->baseUnit?->name;
|
||||||
$item->purchase_unit = $product->purchase_unit ?: $product->large_unit; // Fallback logic same as Create
|
$item->large_unit_id = $product->large_unit_id;
|
||||||
|
$item->large_unit_name = $product->largeUnit?->name;
|
||||||
|
$item->purchase_unit_id = $product->purchase_unit_id;
|
||||||
|
|
||||||
$item->conversion_rate = (float) $product->conversion_rate;
|
$item->conversion_rate = (float) $product->conversion_rate;
|
||||||
// 優先使用採購單位 > 大單位 > 基本單位
|
|
||||||
$item->unit = $product->purchase_unit ?: ($product->large_unit ?: $product->base_unit);
|
// Fetch last price
|
||||||
|
$lastPrice = DB::table('product_vendor')
|
||||||
|
->where('vendor_id', $order->vendor_id)
|
||||||
|
->where('product_id', $product->id)
|
||||||
|
->value('last_price');
|
||||||
|
$item->previousPrice = (float) ($lastPrice ?? 0);
|
||||||
|
|
||||||
|
// 設定當前選中的單位 ID (from saved item)
|
||||||
|
$item->unitId = $item->unit_id;
|
||||||
|
|
||||||
|
// 決定 selectedUnit (用於 UI 顯示)
|
||||||
|
if ($item->unitId && $item->large_unit_id && $item->unitId == $item->large_unit_id) {
|
||||||
|
$item->selectedUnit = 'large';
|
||||||
|
} else {
|
||||||
|
$item->selectedUnit = 'base';
|
||||||
|
}
|
||||||
|
|
||||||
$item->unitPrice = (float) $item->unit_price;
|
$item->unitPrice = (float) $item->unit_price;
|
||||||
}
|
}
|
||||||
return $item;
|
return $item;
|
||||||
@@ -202,7 +225,7 @@ class PurchaseOrderController extends Controller
|
|||||||
{
|
{
|
||||||
$order = PurchaseOrder::with(['items.product'])->findOrFail($id);
|
$order = PurchaseOrder::with(['items.product'])->findOrFail($id);
|
||||||
|
|
||||||
$vendors = Vendor::with('products')->get()->map(function ($vendor) {
|
$vendors = Vendor::with(['products.baseUnit', 'products.largeUnit', 'products.purchaseUnit'])->get()->map(function ($vendor) {
|
||||||
return [
|
return [
|
||||||
'id' => (string) $vendor->id,
|
'id' => (string) $vendor->id,
|
||||||
'name' => $vendor->name,
|
'name' => $vendor->name,
|
||||||
@@ -210,9 +233,11 @@ class PurchaseOrderController extends Controller
|
|||||||
return [
|
return [
|
||||||
'productId' => (string) $product->id,
|
'productId' => (string) $product->id,
|
||||||
'productName' => $product->name,
|
'productName' => $product->name,
|
||||||
'unit' => $product->purchase_unit ?: ($product->large_unit ?: $product->base_unit),
|
'base_unit_id' => $product->base_unit_id,
|
||||||
'base_unit' => $product->base_unit,
|
'base_unit_name' => $product->baseUnit?->name,
|
||||||
'purchase_unit' => $product->purchase_unit ?: $product->large_unit,
|
'large_unit_id' => $product->large_unit_id,
|
||||||
|
'large_unit_name' => $product->largeUnit?->name,
|
||||||
|
'purchase_unit_id' => $product->purchase_unit_id,
|
||||||
'conversion_rate' => (float) $product->conversion_rate,
|
'conversion_rate' => (float) $product->conversion_rate,
|
||||||
'lastPrice' => (float) ($product->pivot->last_price ?? 0),
|
'lastPrice' => (float) ($product->pivot->last_price ?? 0),
|
||||||
];
|
];
|
||||||
@@ -228,17 +253,38 @@ class PurchaseOrderController extends Controller
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Transform items for frontend form
|
// Transform items for frontend form
|
||||||
$order->items->transform(function ($item) {
|
// Transform items for frontend form
|
||||||
|
$vendorId = $order->vendor_id;
|
||||||
|
$order->items->transform(function ($item) use ($vendorId) {
|
||||||
$product = $item->product;
|
$product = $item->product;
|
||||||
if ($product) {
|
if ($product) {
|
||||||
// 手動附加所有必要的屬性 (因為已從 $appends 移除)
|
// 手動附加所有必要的屬性
|
||||||
$item->productId = (string) $product->id; // Ensure consistent ID type
|
$item->productId = (string) $product->id;
|
||||||
$item->productName = $product->name;
|
$item->productName = $product->name;
|
||||||
$item->base_unit = $product->base_unit;
|
$item->base_unit_id = $product->base_unit_id;
|
||||||
$item->purchase_unit = $product->purchase_unit ?: $product->large_unit;
|
$item->base_unit_name = $product->baseUnit?->name;
|
||||||
|
$item->large_unit_id = $product->large_unit_id;
|
||||||
|
$item->large_unit_name = $product->largeUnit?->name;
|
||||||
|
|
||||||
$item->conversion_rate = (float) $product->conversion_rate;
|
$item->conversion_rate = (float) $product->conversion_rate;
|
||||||
// 優先使用採購單位 > 大單位 > 基本單位
|
|
||||||
$item->unit = $product->purchase_unit ?: ($product->large_unit ?: $product->base_unit);
|
// Fetch last price
|
||||||
|
$lastPrice = DB::table('product_vendor')
|
||||||
|
->where('vendor_id', $vendorId)
|
||||||
|
->where('product_id', $product->id)
|
||||||
|
->value('last_price');
|
||||||
|
$item->previousPrice = (float) ($lastPrice ?? 0);
|
||||||
|
|
||||||
|
// 設定當前選中的單位 ID
|
||||||
|
$item->unitId = $item->unit_id; // 資料庫中的 unit_id
|
||||||
|
|
||||||
|
// 決定 selectedUnit (用於 UI 狀態)
|
||||||
|
if ($item->unitId && $item->large_unit_id && $item->unitId == $item->large_unit_id) {
|
||||||
|
$item->selectedUnit = 'large';
|
||||||
|
} else {
|
||||||
|
$item->selectedUnit = 'base';
|
||||||
|
}
|
||||||
|
|
||||||
$item->unitPrice = (float) $item->unit_price;
|
$item->unitPrice = (float) $item->unit_price;
|
||||||
}
|
}
|
||||||
return $item;
|
return $item;
|
||||||
@@ -265,6 +311,7 @@ class PurchaseOrderController extends Controller
|
|||||||
'items.*.productId' => 'required|exists:products,id',
|
'items.*.productId' => 'required|exists:products,id',
|
||||||
'items.*.quantity' => 'required|numeric|min:0.01',
|
'items.*.quantity' => 'required|numeric|min:0.01',
|
||||||
'items.*.unitPrice' => 'required|numeric|min:0',
|
'items.*.unitPrice' => 'required|numeric|min:0',
|
||||||
|
'items.*.unitId' => 'nullable|exists:units,id', // 驗證單位ID
|
||||||
]);
|
]);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -296,6 +343,7 @@ class PurchaseOrderController extends Controller
|
|||||||
$order->items()->create([
|
$order->items()->create([
|
||||||
'product_id' => $item['productId'],
|
'product_id' => $item['productId'],
|
||||||
'quantity' => $item['quantity'],
|
'quantity' => $item['quantity'],
|
||||||
|
'unit_id' => $item['unitId'] ?? null, // 儲存單位ID
|
||||||
'unit_price' => $item['unitPrice'],
|
'unit_price' => $item['unitPrice'],
|
||||||
'subtotal' => $item['quantity'] * $item['unitPrice'],
|
'subtotal' => $item['quantity'] * $item['unitPrice'],
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ class SafetyStockController extends Controller
|
|||||||
{
|
{
|
||||||
$warehouse->load(['inventories.product.category']);
|
$warehouse->load(['inventories.product.category']);
|
||||||
|
|
||||||
$allProducts = Product::with('category')->get();
|
$allProducts = Product::with(['category', 'baseUnit'])->get();
|
||||||
|
|
||||||
// 準備可選商品列表
|
// 準備可選商品列表
|
||||||
$availableProducts = $allProducts->map(function ($product) {
|
$availableProducts = $allProducts->map(function ($product) {
|
||||||
@@ -26,7 +26,7 @@ class SafetyStockController extends Controller
|
|||||||
'id' => (string) $product->id,
|
'id' => (string) $product->id,
|
||||||
'name' => $product->name,
|
'name' => $product->name,
|
||||||
'type' => $product->category ? $product->category->name : '其他',
|
'type' => $product->category ? $product->category->name : '其他',
|
||||||
'unit' => $product->base_unit,
|
'unit' => $product->baseUnit?->name ?? '個',
|
||||||
];
|
];
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -51,7 +51,7 @@ class SafetyStockController extends Controller
|
|||||||
'productName' => $inv->product->name,
|
'productName' => $inv->product->name,
|
||||||
'productType' => $inv->product->category ? $inv->product->category->name : '其他',
|
'productType' => $inv->product->category ? $inv->product->category->name : '其他',
|
||||||
'safetyStock' => (float) $inv->safety_stock,
|
'safetyStock' => (float) $inv->safety_stock,
|
||||||
'unit' => $inv->product->base_unit,
|
'unit' => $inv->product->baseUnit?->name ?? '個',
|
||||||
'updatedAt' => $inv->updated_at->toIso8601String(),
|
'updatedAt' => $inv->updated_at->toIso8601String(),
|
||||||
];
|
];
|
||||||
})->values();
|
})->values();
|
||||||
|
|||||||
@@ -97,7 +97,7 @@ class TransferOrderController extends Controller
|
|||||||
public function getWarehouseInventories(Warehouse $warehouse)
|
public function getWarehouseInventories(Warehouse $warehouse)
|
||||||
{
|
{
|
||||||
$inventories = $warehouse->inventories()
|
$inventories = $warehouse->inventories()
|
||||||
->with(['product:id,name,base_unit,category_id', 'product.category'])
|
->with(['product.baseUnit', 'product.category'])
|
||||||
->where('quantity', '>', 0) // 只回傳有庫存的
|
->where('quantity', '>', 0) // 只回傳有庫存的
|
||||||
->get()
|
->get()
|
||||||
->map(function ($inv) {
|
->map(function ($inv) {
|
||||||
@@ -106,7 +106,7 @@ class TransferOrderController extends Controller
|
|||||||
'productName' => $inv->product->name,
|
'productName' => $inv->product->name,
|
||||||
'batchNumber' => 'BATCH-' . $inv->id, // 模擬批號
|
'batchNumber' => 'BATCH-' . $inv->id, // 模擬批號
|
||||||
'availableQty' => (float) $inv->quantity,
|
'availableQty' => (float) $inv->quantity,
|
||||||
'unit' => $inv->product->base_unit,
|
'unit' => $inv->product->baseUnit?->name ?? '個',
|
||||||
];
|
];
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
70
app/Http/Controllers/UnitController.php
Normal file
70
app/Http/Controllers/UnitController.php
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Models\Unit;
|
||||||
|
use App\Models\Product; // Import Product to check for usage
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class UnitController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Store a newly created resource in storage.
|
||||||
|
*/
|
||||||
|
public function store(Request $request)
|
||||||
|
{
|
||||||
|
$validated = $request->validate([
|
||||||
|
'name' => 'required|string|max:255|unique:units,name',
|
||||||
|
'code' => 'nullable|string|max:50',
|
||||||
|
], [
|
||||||
|
'name.required' => '單位名稱為必填項目',
|
||||||
|
'name.unique' => '該單位名稱已存在',
|
||||||
|
'name.max' => '單位名稱不能超過 255 個字元',
|
||||||
|
'code.max' => '單位代碼不能超過 50 個字元',
|
||||||
|
]);
|
||||||
|
|
||||||
|
Unit::create($validated);
|
||||||
|
|
||||||
|
return redirect()->back()->with('success', '單位已建立');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the specified resource in storage.
|
||||||
|
*/
|
||||||
|
public function update(Request $request, Unit $unit)
|
||||||
|
{
|
||||||
|
$validated = $request->validate([
|
||||||
|
'name' => 'required|string|max:255|unique:units,name,' . $unit->id,
|
||||||
|
'code' => 'nullable|string|max:50',
|
||||||
|
], [
|
||||||
|
'name.required' => '單位名稱為必填項目',
|
||||||
|
'name.unique' => '該單位名稱已存在',
|
||||||
|
'name.max' => '單位名稱不能超過 255 個字元',
|
||||||
|
'code.max' => '單位代碼不能超過 50 個字元',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$unit->update($validated);
|
||||||
|
|
||||||
|
return redirect()->back()->with('success', '單位已更新');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove the specified resource from storage.
|
||||||
|
*/
|
||||||
|
public function destroy(Unit $unit)
|
||||||
|
{
|
||||||
|
// Check if unit is used in any product
|
||||||
|
$isUsed = Product::where('base_unit_id', $unit->id)
|
||||||
|
->orWhere('large_unit_id', $unit->id)
|
||||||
|
->orWhere('purchase_unit_id', $unit->id)
|
||||||
|
->exists();
|
||||||
|
|
||||||
|
if ($isUsed) {
|
||||||
|
return redirect()->back()->with('error', '該單位已被商品使用,無法刪除');
|
||||||
|
}
|
||||||
|
|
||||||
|
$unit->delete();
|
||||||
|
|
||||||
|
return redirect()->back()->with('success', '單位已刪除');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -51,10 +51,10 @@ class VendorController extends Controller
|
|||||||
*/
|
*/
|
||||||
public function show(Vendor $vendor): \Inertia\Response
|
public function show(Vendor $vendor): \Inertia\Response
|
||||||
{
|
{
|
||||||
$vendor->load('products');
|
$vendor->load(['products.baseUnit', 'products.largeUnit']);
|
||||||
return \Inertia\Inertia::render('Vendor/Show', [
|
return \Inertia\Inertia::render('Vendor/Show', [
|
||||||
'vendor' => $vendor,
|
'vendor' => $vendor,
|
||||||
'products' => \App\Models\Product::all(),
|
'products' => \App\Models\Product::with('baseUnit')->get(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -72,9 +72,25 @@ class WarehouseController extends Controller
|
|||||||
|
|
||||||
public function destroy(Warehouse $warehouse)
|
public function destroy(Warehouse $warehouse)
|
||||||
{
|
{
|
||||||
// 真實刪除
|
// 檢查是否有相關聯的採購單
|
||||||
$warehouse->delete();
|
if ($warehouse->purchaseOrders()->exists()) {
|
||||||
|
return redirect()->back()->with('error', '無法刪除:該倉庫有相關聯的採購單,請先處理採購單。');
|
||||||
|
}
|
||||||
|
|
||||||
return redirect()->back()->with('success', '倉庫已刪除');
|
\Illuminate\Support\Facades\DB::transaction(function () use ($warehouse) {
|
||||||
|
// 刪除庫存異動紀錄 (透過庫存關聯)
|
||||||
|
foreach ($warehouse->inventories as $inventory) {
|
||||||
|
// 刪除該庫存的所有異動紀錄
|
||||||
|
$inventory->transactions()->delete();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 刪除庫存項目
|
||||||
|
$warehouse->inventories()->delete();
|
||||||
|
|
||||||
|
// 刪除倉庫
|
||||||
|
$warehouse->delete();
|
||||||
|
});
|
||||||
|
|
||||||
|
return redirect()->back()->with('success', '倉庫及其庫存與紀錄已刪除');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,6 +40,10 @@ class HandleInertiaRequests extends Middleware
|
|||||||
'auth' => [
|
'auth' => [
|
||||||
'user' => $request->user(),
|
'user' => $request->user(),
|
||||||
],
|
],
|
||||||
|
'flash' => [
|
||||||
|
'success' => $request->session()->get('success'),
|
||||||
|
'error' => $request->session()->get('error'),
|
||||||
|
],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,10 +17,10 @@ class Product extends Model
|
|||||||
'category_id',
|
'category_id',
|
||||||
'brand',
|
'brand',
|
||||||
'specification',
|
'specification',
|
||||||
'base_unit',
|
'base_unit_id',
|
||||||
'large_unit',
|
'large_unit_id',
|
||||||
'conversion_rate',
|
'conversion_rate',
|
||||||
'purchase_unit',
|
'purchase_unit_id',
|
||||||
];
|
];
|
||||||
|
|
||||||
protected $casts = [
|
protected $casts = [
|
||||||
@@ -35,6 +35,21 @@ class Product extends Model
|
|||||||
return $this->belongsTo(Category::class);
|
return $this->belongsTo(Category::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function baseUnit(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Unit::class, 'base_unit_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function largeUnit(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Unit::class, 'large_unit_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function purchaseUnit(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Unit::class, 'purchase_unit_id');
|
||||||
|
}
|
||||||
|
|
||||||
public function vendors(): \Illuminate\Database\Eloquent\Relations\BelongsToMany
|
public function vendors(): \Illuminate\Database\Eloquent\Relations\BelongsToMany
|
||||||
{
|
{
|
||||||
return $this->belongsToMany(Vendor::class)->withPivot('last_price')->withTimestamps();
|
return $this->belongsToMany(Vendor::class)->withPivot('last_price')->withTimestamps();
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ class PurchaseOrderItem extends Model
|
|||||||
'purchase_order_id',
|
'purchase_order_id',
|
||||||
'product_id',
|
'product_id',
|
||||||
'quantity',
|
'quantity',
|
||||||
|
'unit_id', // 新增單位ID欄位
|
||||||
'unit_price',
|
'unit_price',
|
||||||
'subtotal',
|
'subtotal',
|
||||||
'received_quantity',
|
'received_quantity',
|
||||||
@@ -26,25 +27,33 @@ class PurchaseOrderItem extends Model
|
|||||||
'received_quantity' => 'decimal:2',
|
'received_quantity' => 'decimal:2',
|
||||||
];
|
];
|
||||||
|
|
||||||
// 移除 $appends 以避免自動附加導致的錯誤
|
|
||||||
// 這些屬性將在 Controller 中需要時手動附加
|
|
||||||
// protected $appends = ['productName', 'unit'];
|
|
||||||
|
|
||||||
public function getProductNameAttribute(): string
|
public function getProductNameAttribute(): string
|
||||||
{
|
{
|
||||||
return $this->product?->name ?? '';
|
return $this->product?->name ?? '';
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getUnitAttribute(): string
|
// 關聯單位
|
||||||
|
public function unit(): \Illuminate\Database\Eloquent\Relations\BelongsTo
|
||||||
{
|
{
|
||||||
// 優先使用採購單位 > 大單位 > 基本單位
|
return $this->belongsTo(Unit::class);
|
||||||
// 與 PurchaseOrderController 的邏輯保持一致
|
}
|
||||||
|
|
||||||
|
public function getUnitNameAttribute(): string
|
||||||
|
{
|
||||||
|
// 優先使用關聯的 unit
|
||||||
|
if ($this->unit) {
|
||||||
|
return $this->unit->name;
|
||||||
|
}
|
||||||
|
|
||||||
if (!$this->product) {
|
if (!$this->product) {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->product->purchase_unit
|
// Fallback: 嘗試從 Product 的關聯單位獲取
|
||||||
?: ($this->product->large_unit ?: $this->product->base_unit);
|
return $this->product->purchaseUnit?->name
|
||||||
|
?? $this->product->largeUnit?->name
|
||||||
|
?? $this->product->baseUnit?->name
|
||||||
|
?? '';
|
||||||
}
|
}
|
||||||
|
|
||||||
public function purchaseOrder(): BelongsTo
|
public function purchaseOrder(): BelongsTo
|
||||||
|
|||||||
17
app/Models/Unit.php
Normal file
17
app/Models/Unit.php
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
|
class Unit extends Model
|
||||||
|
{
|
||||||
|
/** @use HasFactory<\Database\Factories\UnitFactory> */
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'name',
|
||||||
|
'code',
|
||||||
|
];
|
||||||
|
}
|
||||||
29
database/migrations/2026_01_08_103000_create_units_table.php
Normal file
29
database/migrations/2026_01_08_103000_create_units_table.php
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('units', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->string('name')->unique()->comment('單位名稱');
|
||||||
|
$table->string('code')->nullable()->comment('單位代碼 (如: kg)');
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('units');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('products', function (Blueprint $table) {
|
||||||
|
// Drop old string columns
|
||||||
|
$table->dropColumn(['base_unit', 'large_unit', 'purchase_unit']);
|
||||||
|
|
||||||
|
// Add new foreign key columns
|
||||||
|
$table->foreignId('base_unit_id')->nullable()->after('specification')->constrained('units')->nullOnDelete()->comment('基本庫存單位ID');
|
||||||
|
$table->foreignId('large_unit_id')->nullable()->after('base_unit_id')->constrained('units')->nullOnDelete()->comment('大單位ID');
|
||||||
|
$table->foreignId('purchase_unit_id')->nullable()->after('conversion_rate')->constrained('units')->nullOnDelete()->comment('採購單位ID');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('products', function (Blueprint $table) {
|
||||||
|
// Remove foreign keys
|
||||||
|
$table->dropForeign(['base_unit_id']);
|
||||||
|
$table->dropForeign(['large_unit_id']);
|
||||||
|
$table->dropForeign(['purchase_unit_id']);
|
||||||
|
$table->dropColumn(['base_unit_id', 'large_unit_id', 'purchase_unit_id']);
|
||||||
|
|
||||||
|
// Add back string columns (nullable since data is lost)
|
||||||
|
$table->string('base_unit')->nullable()->comment('基本庫存單位 (e.g. g, ml)');
|
||||||
|
$table->string('large_unit')->nullable()->comment('大單位 (e.g. 桶, 箱)');
|
||||||
|
$table->string('purchase_unit')->nullable()->comment('採購單位');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('purchase_order_items', function (Blueprint $table) {
|
||||||
|
$table->string('unit')->nullable()->after('quantity')->comment('採購單位');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('purchase_order_items', function (Blueprint $table) {
|
||||||
|
$table->dropColumn('unit');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('purchase_order_items', function (Blueprint $table) {
|
||||||
|
$table->foreignId('unit_id')->nullable()->after('quantity')->comment('選擇的單位ID')->constrained('units')->nullOnDelete();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('purchase_order_items', function (Blueprint $table) {
|
||||||
|
$table->dropForeign(['unit_id']);
|
||||||
|
$table->dropColumn('unit_id');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
36
database/seeders/UnitSeeder.php
Normal file
36
database/seeders/UnitSeeder.php
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Database\Seeders;
|
||||||
|
|
||||||
|
use App\Models\Unit;
|
||||||
|
use Illuminate\Database\Seeder;
|
||||||
|
|
||||||
|
class UnitSeeder extends Seeder
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the database seeds.
|
||||||
|
*/
|
||||||
|
public function run(): void
|
||||||
|
{
|
||||||
|
$units = [
|
||||||
|
['name' => '個', 'code' => 'pc'],
|
||||||
|
['name' => '箱', 'code' => 'box'],
|
||||||
|
['name' => '瓶', 'code' => 'btl'],
|
||||||
|
['name' => '包', 'code' => 'pkg'],
|
||||||
|
['name' => '公斤', 'code' => 'kg'],
|
||||||
|
['name' => '公克', 'code' => 'g'],
|
||||||
|
['name' => '公升', 'code' => 'l'],
|
||||||
|
['name' => '毫升', 'code' => 'ml'],
|
||||||
|
['name' => '籃', 'code' => 'bsk'],
|
||||||
|
['name' => '桶', 'code' => 'bucket'],
|
||||||
|
['name' => '罐', 'code' => 'can'],
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($units as $unit) {
|
||||||
|
Unit::firstOrCreate(
|
||||||
|
['name' => $unit['name']],
|
||||||
|
['code' => $unit['code']]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -59,7 +59,6 @@ export default function CategoryManagerDialog({
|
|||||||
post(route("categories.store"), {
|
post(route("categories.store"), {
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
reset();
|
reset();
|
||||||
toast.success("分類已新增");
|
|
||||||
},
|
},
|
||||||
onError: (errors) => {
|
onError: (errors) => {
|
||||||
toast.error("新增失敗: " + (errors.name || "未知錯誤"));
|
toast.error("新增失敗: " + (errors.name || "未知錯誤"));
|
||||||
@@ -83,7 +82,6 @@ export default function CategoryManagerDialog({
|
|||||||
router.put(route("categories.update", id), { name: editName }, {
|
router.put(route("categories.update", id), { name: editName }, {
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
setEditingId(null);
|
setEditingId(null);
|
||||||
toast.success("分類已更新");
|
|
||||||
},
|
},
|
||||||
onError: (errors) => {
|
onError: (errors) => {
|
||||||
toast.error("更新失敗: " + (errors.name || "未知錯誤"));
|
toast.error("更新失敗: " + (errors.name || "未知錯誤"));
|
||||||
@@ -94,7 +92,7 @@ export default function CategoryManagerDialog({
|
|||||||
const handleDelete = (id: number) => {
|
const handleDelete = (id: number) => {
|
||||||
router.delete(route("categories.destroy", id), {
|
router.delete(route("categories.destroy", id), {
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast.success("分類已刪除");
|
// 不在此處理 toast,交由全域 flash 處理
|
||||||
},
|
},
|
||||||
onError: () => {
|
onError: () => {
|
||||||
toast.error("刪除失敗,請確認該分類下無商品");
|
toast.error("刪除失敗,請確認該分類下無商品");
|
||||||
|
|||||||
@@ -21,19 +21,15 @@ import {
|
|||||||
import { useForm } from "@inertiajs/react";
|
import { useForm } from "@inertiajs/react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import type { Product, Category } from "@/Pages/Product/Index";
|
import type { Product, Category } from "@/Pages/Product/Index";
|
||||||
import {
|
import type { Unit } from "@/Components/Unit/UnitManagerDialog";
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from "@/Components/ui/dropdown-menu";
|
|
||||||
import { ChevronDown } from "lucide-react";
|
|
||||||
|
|
||||||
interface ProductDialogProps {
|
interface ProductDialogProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
product: Product | null;
|
product: Product | null;
|
||||||
categories: Category[];
|
categories: Category[];
|
||||||
|
units: Unit[];
|
||||||
onSave?: (product: any) => void; // Legacy prop, can be removed if fully switching to Inertia submit within dialog
|
onSave?: (product: any) => void; // Legacy prop, can be removed if fully switching to Inertia submit within dialog
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,16 +38,17 @@ export default function ProductDialog({
|
|||||||
onOpenChange,
|
onOpenChange,
|
||||||
product,
|
product,
|
||||||
categories,
|
categories,
|
||||||
|
units,
|
||||||
}: ProductDialogProps) {
|
}: ProductDialogProps) {
|
||||||
const { data, setData, post, put, processing, errors, reset, clearErrors } = useForm({
|
const { data, setData, post, put, processing, errors, reset, clearErrors } = useForm({
|
||||||
name: "",
|
name: "",
|
||||||
category_id: "",
|
category_id: "",
|
||||||
brand: "",
|
brand: "",
|
||||||
specification: "",
|
specification: "",
|
||||||
base_unit: "公斤",
|
base_unit_id: "",
|
||||||
large_unit: "",
|
large_unit_id: "",
|
||||||
conversion_rate: "",
|
conversion_rate: "",
|
||||||
purchase_unit: "",
|
purchase_unit_id: "",
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -63,10 +60,10 @@ export default function ProductDialog({
|
|||||||
category_id: product.category_id.toString(),
|
category_id: product.category_id.toString(),
|
||||||
brand: product.brand || "",
|
brand: product.brand || "",
|
||||||
specification: product.specification || "",
|
specification: product.specification || "",
|
||||||
base_unit: product.base_unit,
|
base_unit_id: product.base_unit_id?.toString() || "",
|
||||||
large_unit: product.large_unit || "",
|
large_unit_id: product.large_unit_id?.toString() || "",
|
||||||
conversion_rate: product.conversion_rate ? product.conversion_rate.toString() : "",
|
conversion_rate: product.conversion_rate ? product.conversion_rate.toString() : "",
|
||||||
purchase_unit: product.purchase_unit || "",
|
purchase_unit_id: product.purchase_unit_id?.toString() || "",
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
reset();
|
reset();
|
||||||
@@ -188,50 +185,52 @@ export default function ProductDialog({
|
|||||||
<h3 className="text-lg font-medium border-b pb-2">單位設定</h3>
|
<h3 className="text-lg font-medium border-b pb-2">單位設定</h3>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="base_unit">
|
<Label htmlFor="base_unit_id">
|
||||||
基本庫存單位 <span className="text-red-500">*</span>
|
基本庫存單位 <span className="text-red-500">*</span>
|
||||||
</Label>
|
</Label>
|
||||||
<div className="flex gap-2">
|
<Select
|
||||||
<Input
|
value={data.base_unit_id}
|
||||||
id="base_unit"
|
onValueChange={(value) => setData("base_unit_id", value)}
|
||||||
value={data.base_unit}
|
>
|
||||||
onChange={(e) => setData("base_unit", e.target.value)}
|
<SelectTrigger id="base_unit_id" className={errors.base_unit_id ? "border-red-500" : ""}>
|
||||||
placeholder="可輸入或選擇..."
|
<SelectValue placeholder="選擇單位" />
|
||||||
className={errors.base_unit ? "border-red-500 flex-1" : "flex-1"}
|
</SelectTrigger>
|
||||||
/>
|
<SelectContent>
|
||||||
<DropdownMenu>
|
{units.map((unit) => (
|
||||||
<DropdownMenuTrigger asChild>
|
<SelectItem key={unit.id} value={unit.id.toString()}>
|
||||||
<Button variant="outline" size="icon" className="shrink-0">
|
{unit.name}
|
||||||
<ChevronDown className="h-4 w-4" />
|
</SelectItem>
|
||||||
</Button>
|
))}
|
||||||
</DropdownMenuTrigger>
|
</SelectContent>
|
||||||
<DropdownMenuContent align="end">
|
</Select>
|
||||||
{["公斤", "公克", "公升", "毫升", "個", "支", "包", "罐", "瓶", "箱", "袋"].map((u) => (
|
{errors.base_unit_id && <p className="text-sm text-red-500">{errors.base_unit_id}</p>}
|
||||||
<DropdownMenuItem key={u} onClick={() => setData("base_unit", u)}>
|
|
||||||
{u}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
))}
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
</div>
|
|
||||||
{errors.base_unit && <p className="text-sm text-red-500">{errors.base_unit}</p>}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="large_unit">大單位</Label>
|
<Label htmlFor="large_unit_id">大單位</Label>
|
||||||
<Input
|
<Select
|
||||||
id="large_unit"
|
value={data.large_unit_id}
|
||||||
value={data.large_unit}
|
onValueChange={(value) => setData("large_unit_id", value)}
|
||||||
onChange={(e) => setData("large_unit", e.target.value)}
|
>
|
||||||
placeholder="例:箱、袋"
|
<SelectTrigger id="large_unit_id" className={errors.large_unit_id ? "border-red-500" : ""}>
|
||||||
/>
|
<SelectValue placeholder="無" />
|
||||||
{errors.large_unit && <p className="text-sm text-red-500">{errors.large_unit}</p>}
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="none">無</SelectItem>
|
||||||
|
{units.map((unit) => (
|
||||||
|
<SelectItem key={unit.id} value={unit.id.toString()}>
|
||||||
|
{unit.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
{errors.large_unit_id && <p className="text-sm text-red-500">{errors.large_unit_id}</p>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="conversion_rate">
|
<Label htmlFor="conversion_rate">
|
||||||
換算率
|
換算率
|
||||||
{data.large_unit && <span className="text-red-500">*</span>}
|
{data.large_unit_id && <span className="text-red-500">*</span>}
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="conversion_rate"
|
id="conversion_rate"
|
||||||
@@ -239,27 +238,17 @@ export default function ProductDialog({
|
|||||||
step="0.0001"
|
step="0.0001"
|
||||||
value={data.conversion_rate}
|
value={data.conversion_rate}
|
||||||
onChange={(e) => setData("conversion_rate", e.target.value)}
|
onChange={(e) => setData("conversion_rate", e.target.value)}
|
||||||
placeholder={data.large_unit ? `1 ${data.large_unit} = ? ${data.base_unit}` : ""}
|
placeholder={data.large_unit_id && data.base_unit_id ? `1 ${units.find(u => u.id.toString() === data.large_unit_id)?.name} = ? ${units.find(u => u.id.toString() === data.base_unit_id)?.name}` : ""}
|
||||||
disabled={!data.large_unit}
|
disabled={!data.large_unit_id}
|
||||||
/>
|
/>
|
||||||
{errors.conversion_rate && <p className="text-sm text-red-500">{errors.conversion_rate}</p>}
|
{errors.conversion_rate && <p className="text-sm text-red-500">{errors.conversion_rate}</p>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="purchase_unit">採購單位</Label>
|
|
||||||
<Input
|
|
||||||
id="purchase_unit"
|
|
||||||
value={data.purchase_unit}
|
|
||||||
onChange={(e) => setData("purchase_unit", e.target.value)}
|
|
||||||
placeholder="通常同大單位"
|
|
||||||
/>
|
|
||||||
{errors.purchase_unit && <p className="text-sm text-red-500">{errors.purchase_unit}</p>}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{data.large_unit && data.base_unit && data.conversion_rate && (
|
{data.large_unit_id && data.base_unit_id && data.conversion_rate && (
|
||||||
<div className="bg-blue-50 p-3 rounded text-sm text-blue-700">
|
<div className="bg-blue-50 p-3 rounded text-sm text-blue-700">
|
||||||
預覽:1 {data.large_unit} = {data.conversion_rate} {data.base_unit}
|
預覽:1 {units.find(u => u.id.toString() === data.large_unit_id)?.name} = {data.conversion_rate} {units.find(u => u.id.toString() === data.base_unit_id)?.name}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -89,7 +89,11 @@ export default function ProductTable({
|
|||||||
分類 <SortIcon field="category_id" />
|
分類 <SortIcon field="category_id" />
|
||||||
</button>
|
</button>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead>基本單位</TableHead>
|
<TableHead>
|
||||||
|
<button onClick={() => onSort("base_unit_id")} className="flex items-center hover:text-gray-900 font-semibold">
|
||||||
|
基本單位 <SortIcon field="base_unit_id" />
|
||||||
|
</button>
|
||||||
|
</TableHead>
|
||||||
<TableHead>換算率</TableHead>
|
<TableHead>換算率</TableHead>
|
||||||
<TableHead className="text-center">操作</TableHead>
|
<TableHead className="text-center">操作</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
@@ -121,11 +125,11 @@ export default function ProductTable({
|
|||||||
{product.category?.name || '-'}
|
{product.category?.name || '-'}
|
||||||
</Badge>
|
</Badge>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>{product.base_unit}</TableCell>
|
<TableCell>{product.base_unit?.name || '-'}</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
{product.large_unit ? (
|
{product.large_unit ? (
|
||||||
<span className="text-sm text-gray-500">
|
<span className="text-sm text-gray-500">
|
||||||
1 {product.large_unit} = {Number(product.conversion_rate)} {product.base_unit}
|
1 {product.large_unit?.name} = {Number(product.conversion_rate)} {product.base_unit?.name}
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
'-'
|
'-'
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ import {
|
|||||||
TableRow,
|
TableRow,
|
||||||
} from "@/Components/ui/table";
|
} from "@/Components/ui/table";
|
||||||
import type { PurchaseOrderItem, Supplier } from "@/types/purchase-order";
|
import type { PurchaseOrderItem, Supplier } from "@/types/purchase-order";
|
||||||
import { isPriceAlert, formatCurrency } from "@/utils/purchase-order";
|
import { formatCurrency } from "@/utils/purchase-order";
|
||||||
|
|
||||||
interface PurchaseOrderItemsTableProps {
|
interface PurchaseOrderItemsTableProps {
|
||||||
items: PurchaseOrderItem[];
|
items: PurchaseOrderItem[];
|
||||||
@@ -46,12 +46,13 @@ export function PurchaseOrderItemsTable({
|
|||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow className="bg-gray-50 hover:bg-gray-50">
|
<TableRow className="bg-gray-50 hover:bg-gray-50">
|
||||||
<TableHead className="w-[25%] text-left">商品名稱</TableHead>
|
<TableHead className="w-[20%] text-left">商品名稱</TableHead>
|
||||||
<TableHead className="w-[10%] text-left">數量</TableHead>
|
<TableHead className="w-[10%] text-left">數量</TableHead>
|
||||||
<TableHead className="w-[10%] text-left">採購單位</TableHead>
|
<TableHead className="w-[12%] text-left">單位</TableHead>
|
||||||
<TableHead className="w-[15%] text-left">換算基本單位</TableHead>
|
<TableHead className="w-[12%] text-left">換算基本單位</TableHead>
|
||||||
<TableHead className="w-[15%] text-left">預估單價</TableHead>
|
<TableHead className="w-[12%] text-left">金額</TableHead>
|
||||||
<TableHead className="w-[15%] text-left">小計</TableHead>
|
<TableHead className="w-[12%] text-left">小計</TableHead>
|
||||||
|
<TableHead className="w-[15%] text-left">單價 / 基本單位</TableHead>
|
||||||
{!isReadOnly && <TableHead className="w-[5%]"></TableHead>}
|
{!isReadOnly && <TableHead className="w-[5%]"></TableHead>}
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
@@ -59,137 +60,184 @@ export function PurchaseOrderItemsTable({
|
|||||||
{items.length === 0 ? (
|
{items.length === 0 ? (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell
|
<TableCell
|
||||||
colSpan={isReadOnly ? 6 : 7}
|
colSpan={isReadOnly ? 7 : 8}
|
||||||
className="text-center text-gray-400 py-12 italic"
|
className="text-center text-gray-400 py-12 italic"
|
||||||
>
|
>
|
||||||
{isDisabled ? "請先選擇供應商後才能新增商品" : "尚未新增任何商品項"}
|
{isDisabled ? "請先選擇供應商後才能新增商品" : "尚未新增任何商品項"}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
) : (
|
) : (
|
||||||
items.map((item, index) => (
|
items.map((item, index) => {
|
||||||
<TableRow key={index}>
|
// 計算換算後的單價 (基本單位單價)
|
||||||
{/* 商品選擇 */}
|
const convertedUnitPrice = item.selectedUnit === 'large' && item.conversion_rate
|
||||||
<TableCell>
|
? item.unitPrice / item.conversion_rate
|
||||||
{isReadOnly ? (
|
: item.unitPrice;
|
||||||
<span className="font-medium">{item.productName}</span>
|
|
||||||
) : (
|
|
||||||
<Select
|
|
||||||
value={item.productId}
|
|
||||||
onValueChange={(value) =>
|
|
||||||
onItemChange?.(index, "productId", value)
|
|
||||||
}
|
|
||||||
disabled={isDisabled}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="h-10 border-gray-200">
|
|
||||||
<SelectValue placeholder="選擇商品" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{supplier?.commonProducts.map((product) => (
|
|
||||||
<SelectItem key={product.productId} value={product.productId}>
|
|
||||||
{product.productName}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
{(!supplier || supplier.commonProducts.length === 0) && (
|
|
||||||
<div className="p-2 text-sm text-gray-400 text-center">無可用商品</div>
|
|
||||||
)}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
)}
|
|
||||||
</TableCell>
|
|
||||||
|
|
||||||
{/* 數量 */}
|
return (
|
||||||
<TableCell className="text-left">
|
<TableRow key={index}>
|
||||||
{isReadOnly ? (
|
{/* 商品選擇 */}
|
||||||
<span>{Math.floor(item.quantity)}</span>
|
<TableCell>
|
||||||
) : (
|
{isReadOnly ? (
|
||||||
<Input
|
<span className="font-medium">{item.productName}</span>
|
||||||
type="number"
|
) : (
|
||||||
min="0"
|
<Select
|
||||||
step="1"
|
value={item.productId}
|
||||||
value={item.quantity === 0 ? "" : Math.floor(item.quantity)}
|
onValueChange={(value) =>
|
||||||
onChange={(e) =>
|
onItemChange?.(index, "productId", value)
|
||||||
onItemChange?.(index, "quantity", Math.floor(Number(e.target.value)))
|
}
|
||||||
}
|
disabled={isDisabled}
|
||||||
disabled={isDisabled}
|
>
|
||||||
className="h-10 text-left border-gray-200 w-24"
|
<SelectTrigger className="h-10 border-gray-200">
|
||||||
/>
|
<SelectValue placeholder="選擇商品" />
|
||||||
)}
|
</SelectTrigger>
|
||||||
</TableCell>
|
<SelectContent>
|
||||||
|
{supplier?.commonProducts.map((product) => (
|
||||||
|
<SelectItem key={product.productId} value={product.productId}>
|
||||||
|
{product.productName}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
{(!supplier || supplier.commonProducts.length === 0) && (
|
||||||
|
<div className="p-2 text-sm text-gray-400 text-center">無可用商品</div>
|
||||||
|
)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
|
||||||
{/* 採購單位 */}
|
{/* 數量 */}
|
||||||
<TableCell>
|
<TableCell className="text-left">
|
||||||
<span className="text-gray-500 font-medium">{item.unit || "-"}</span>
|
{isReadOnly ? (
|
||||||
</TableCell>
|
<span>{Math.floor(item.quantity)}</span>
|
||||||
|
) : (
|
||||||
{/* 換算基本單位 */}
|
|
||||||
<TableCell>
|
|
||||||
<span className="text-gray-500 font-medium">
|
|
||||||
{item.conversion_rate && item.base_unit
|
|
||||||
? `${parseFloat((item.quantity * item.conversion_rate).toFixed(2))} ${item.base_unit}`
|
|
||||||
: "-"}
|
|
||||||
</span>
|
|
||||||
</TableCell>
|
|
||||||
|
|
||||||
{/* 單價 */}
|
|
||||||
<TableCell className="text-left">
|
|
||||||
{isReadOnly ? (
|
|
||||||
<span className="font-medium text-gray-900">{formatCurrency(item.unitPrice)}</span>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
min="0"
|
min="0"
|
||||||
step="0.1"
|
step="1"
|
||||||
value={item.unitPrice || ""}
|
value={item.quantity === 0 ? "" : Math.floor(item.quantity)}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
onItemChange?.(index, "unitPrice", Number(e.target.value))
|
onItemChange?.(index, "quantity", Math.floor(Number(e.target.value)))
|
||||||
}
|
}
|
||||||
disabled={isDisabled}
|
disabled={isDisabled}
|
||||||
className={`h-10 text-left w-32 ${
|
className="h-10 text-left border-gray-200 w-24"
|
||||||
// 如果有數量但沒有單價,顯示錯誤樣式
|
|
||||||
item.quantity > 0 && (!item.unitPrice || item.unitPrice <= 0)
|
|
||||||
? "border-red-400 bg-red-50 focus-visible:ring-red-500"
|
|
||||||
: isPriceAlert(item.unitPrice, item.previousPrice)
|
|
||||||
? "border-amber-400 bg-amber-50 focus-visible:ring-amber-500"
|
|
||||||
: "border-gray-200"
|
|
||||||
}`}
|
|
||||||
/>
|
/>
|
||||||
{/* 錯誤提示:有數量但沒有單價 */}
|
)}
|
||||||
{item.quantity > 0 && (!item.unitPrice || item.unitPrice <= 0) && (
|
</TableCell>
|
||||||
<p className="text-[10px] text-red-600 font-medium">
|
|
||||||
❌ 請填寫預估單價
|
{/* 單位選擇 */}
|
||||||
</p>
|
<TableCell>
|
||||||
)}
|
{!isReadOnly && item.large_unit_id ? (
|
||||||
{/* 價格警示:單價高於上次 */}
|
<Select
|
||||||
{item.unitPrice > 0 && isPriceAlert(item.unitPrice, item.previousPrice) && (
|
value={item.selectedUnit || 'base'}
|
||||||
<p className="text-[10px] text-amber-600 font-medium animate-pulse">
|
onValueChange={(value) =>
|
||||||
⚠️ 高於上次: {formatCurrency(item.previousPrice || 0)}
|
onItemChange?.(index, "selectedUnit", value)
|
||||||
</p>
|
}
|
||||||
|
disabled={isDisabled}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-10 border-gray-200 w-24">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent className="z-[9999]">
|
||||||
|
<SelectItem value="base">{item.base_unit_name || "個"}</SelectItem>
|
||||||
|
<SelectItem value="large">{item.large_unit_name}</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
) : (
|
||||||
|
<span className="text-gray-500 font-medium">
|
||||||
|
{item.selectedUnit === 'large' && item.large_unit_name
|
||||||
|
? item.large_unit_name
|
||||||
|
: (item.base_unit_name || "個")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
|
||||||
|
{/* 換算基本單位 */}
|
||||||
|
<TableCell>
|
||||||
|
<div className="text-gray-700 font-medium">
|
||||||
|
<span>
|
||||||
|
{item.selectedUnit === 'large' && item.conversion_rate
|
||||||
|
? item.quantity * item.conversion_rate
|
||||||
|
: item.quantity}
|
||||||
|
</span>
|
||||||
|
<span className="ml-1 text-gray-500 text-sm">{item.base_unit_name || "個"}</span>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
|
||||||
|
{/* 單價 */}
|
||||||
|
<TableCell className="text-left">
|
||||||
|
{isReadOnly ? (
|
||||||
|
<span className="font-medium text-gray-900">{formatCurrency(item.unitPrice)}</span>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step="0.1"
|
||||||
|
value={item.unitPrice || ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
onItemChange?.(index, "unitPrice", Number(e.target.value))
|
||||||
|
}
|
||||||
|
disabled={isDisabled}
|
||||||
|
className={`h-10 text-left w-32 ${
|
||||||
|
// 如果有數量但沒有單價,顯示錯誤樣式
|
||||||
|
item.quantity > 0 && (!item.unitPrice || item.unitPrice <= 0)
|
||||||
|
? "border-red-400 bg-red-50 focus-visible:ring-red-500"
|
||||||
|
: "border-gray-200"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
{/* 錯誤提示 (保留必填提示) */}
|
||||||
|
{item.quantity > 0 && (!item.unitPrice || item.unitPrice <= 0) && (
|
||||||
|
<p className="text-[10px] text-red-600 font-medium">
|
||||||
|
❌ 請填寫金額
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
|
||||||
|
{/* 小計 */}
|
||||||
|
<TableCell className="text-left">
|
||||||
|
<span className="font-bold text-primary">{formatCurrency(item.subtotal)}</span>
|
||||||
|
</TableCell>
|
||||||
|
|
||||||
|
{/* 換算採購單價 / 基本單位 */}
|
||||||
|
<TableCell className="text-left">
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<div className="text-gray-500 font-medium text-sm">
|
||||||
|
{formatCurrency(convertedUnitPrice)} / {item.base_unit_name || "個"}
|
||||||
|
</div>
|
||||||
|
{convertedUnitPrice > 0 && item.previousPrice && item.previousPrice > 0 && (
|
||||||
|
<>
|
||||||
|
{convertedUnitPrice > item.previousPrice && (
|
||||||
|
<p className="text-[10px] text-amber-600 font-medium animate-pulse">
|
||||||
|
⚠️ 高於上次: {formatCurrency(item.previousPrice)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{convertedUnitPrice < item.previousPrice && (
|
||||||
|
<p className="text-[10px] text-green-600 font-medium">
|
||||||
|
📉 低於上次: {formatCurrency(item.previousPrice)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</TableCell>
|
|
||||||
|
|
||||||
{/* 小計 */}
|
|
||||||
<TableCell className="text-left">
|
|
||||||
<span className="font-bold text-primary">{formatCurrency(item.subtotal)}</span>
|
|
||||||
</TableCell>
|
|
||||||
|
|
||||||
{/* 刪除按鈕 */}
|
|
||||||
{!isReadOnly && onRemoveItem && (
|
|
||||||
<TableCell className="text-center">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
onClick={() => onRemoveItem(index)}
|
|
||||||
className="h-8 w-8 text-gray-300 hover:text-red-500 hover:bg-red-50 transition-colors"
|
|
||||||
>
|
|
||||||
<Trash2 className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</TableCell>
|
</TableCell>
|
||||||
)}
|
|
||||||
</TableRow>
|
{/* 刪除按鈕 */}
|
||||||
))
|
{!isReadOnly && onRemoveItem && (
|
||||||
|
<TableCell className="text-center">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => onRemoveItem(index)}
|
||||||
|
className="h-8 w-8 text-gray-300 hover:text-red-500 hover:bg-red-50 transition-colors"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
)}
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
})
|
||||||
)}
|
)}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
|
|||||||
309
resources/js/Components/Unit/UnitManagerDialog.tsx
Normal file
309
resources/js/Components/Unit/UnitManagerDialog.tsx
Normal file
@@ -0,0 +1,309 @@
|
|||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/Components/ui/dialog";
|
||||||
|
import { Button } from "@/Components/ui/button";
|
||||||
|
import { Input } from "@/Components/ui/input";
|
||||||
|
import { Label } from "@/Components/ui/label";
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/Components/ui/table";
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogTrigger,
|
||||||
|
} from "@/Components/ui/alert-dialog";
|
||||||
|
import { router, useForm } from "@inertiajs/react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { Trash2, Edit2, Check, X, Plus, Loader2 } from "lucide-react";
|
||||||
|
|
||||||
|
export interface Unit {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
code: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UnitManagerDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
units: Unit[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function UnitManagerDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
units,
|
||||||
|
}: UnitManagerDialogProps) {
|
||||||
|
const [editingId, setEditingId] = useState<number | null>(null);
|
||||||
|
const [editName, setEditName] = useState("");
|
||||||
|
const [editCode, setEditCode] = useState("");
|
||||||
|
|
||||||
|
const { data, setData, post, processing, reset, errors, clearErrors } = useForm({
|
||||||
|
name: "",
|
||||||
|
code: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) {
|
||||||
|
reset();
|
||||||
|
clearErrors();
|
||||||
|
setEditingId(null);
|
||||||
|
}
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const handleAdd = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!data.name.trim()) return;
|
||||||
|
|
||||||
|
post(route("units.store"), {
|
||||||
|
onSuccess: () => {
|
||||||
|
reset();
|
||||||
|
},
|
||||||
|
onError: (errors) => {
|
||||||
|
toast.error("新增失敗: " + (errors.name || errors.code || "未知錯誤"));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const startEdit = (unit: Unit) => {
|
||||||
|
setEditingId(unit.id);
|
||||||
|
setEditName(unit.name);
|
||||||
|
setEditCode(unit.code || "");
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancelEdit = () => {
|
||||||
|
setEditingId(null);
|
||||||
|
setEditName("");
|
||||||
|
setEditCode("");
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveEdit = (id: number) => {
|
||||||
|
if (!editName.trim()) return;
|
||||||
|
|
||||||
|
router.put(route("units.update", id), { name: editName, code: editCode }, {
|
||||||
|
onSuccess: () => {
|
||||||
|
setEditingId(null);
|
||||||
|
},
|
||||||
|
onError: (errors) => {
|
||||||
|
toast.error("更新失敗: " + (errors.name || errors.code || "未知錯誤"));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = (id: number) => {
|
||||||
|
router.delete(route("units.destroy", id), {
|
||||||
|
onSuccess: () => {
|
||||||
|
// 由全域 flash 處理
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast.error("刪除失敗,請確認該單位無關聯商品");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="max-w-2xl max-h-[90vh] overflow-hidden flex flex-col">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>管理單位</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
在此新增、修改或刪除常用單位。刪除前請確認無關聯商品。
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto py-4 space-y-6">
|
||||||
|
{/* Add New Section */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="text-sm font-medium border-l-4 border-primary pl-2">快速新增</h3>
|
||||||
|
<form onSubmit={handleAdd} className="flex items-end gap-3 p-4 bg-white border rounded-lg shadow-sm">
|
||||||
|
<div className="flex-1 grid grid-cols-2 gap-3">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="new-unit-name" className="text-xs text-gray-500">單位名稱</Label>
|
||||||
|
<Input
|
||||||
|
id="new-unit-name"
|
||||||
|
placeholder="例如: 箱, 包"
|
||||||
|
value={data.name}
|
||||||
|
onChange={(e) => setData("name", e.target.value)}
|
||||||
|
className={errors.name ? "border-red-500" : ""}
|
||||||
|
/>
|
||||||
|
{errors.name && <p className="text-xs text-red-500 mt-1">{errors.name}</p>}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="new-unit-code" className="text-xs text-gray-500">代碼 (選填)</Label>
|
||||||
|
<Input
|
||||||
|
id="new-unit-code"
|
||||||
|
placeholder="例如: box, kg"
|
||||||
|
value={data.code}
|
||||||
|
onChange={(e) => setData("code", e.target.value)}
|
||||||
|
className={errors.code ? "border-red-500" : ""}
|
||||||
|
/>
|
||||||
|
{errors.code && <p className="text-xs text-red-500 mt-1">{errors.code}</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button type="submit" disabled={processing} className="button-filled-primary h-10 px-6">
|
||||||
|
{processing ? (
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin mr-2" />
|
||||||
|
) : (
|
||||||
|
<Plus className="w-4 h-4 mr-2" />
|
||||||
|
)}
|
||||||
|
新增
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* List Section */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<h3 className="text-sm font-medium border-l-4 border-primary pl-2">現有單位</h3>
|
||||||
|
<span className="text-xs text-gray-400">共 {units.length} 個項目</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white border rounded-lg shadow-sm overflow-hidden">
|
||||||
|
<Table>
|
||||||
|
<TableHeader className="bg-gray-50">
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="w-[50px] font-medium text-gray-700">#</TableHead>
|
||||||
|
<TableHead className="font-medium text-gray-700">單位名稱</TableHead>
|
||||||
|
<TableHead className="font-medium text-gray-700">代碼</TableHead>
|
||||||
|
<TableHead className="w-[140px] text-right font-medium text-gray-700">操作</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{units.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={4} className="text-center py-12 text-gray-400">
|
||||||
|
目前尚無單位,請從上方新增。
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
units.map((unit, index) => (
|
||||||
|
<TableRow key={unit.id}>
|
||||||
|
<TableCell className="py-3 text-center text-gray-500 font-medium">
|
||||||
|
{index + 1}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="py-3">
|
||||||
|
{editingId === unit.id ? (
|
||||||
|
<Input
|
||||||
|
value={editName}
|
||||||
|
onChange={(e) => setEditName(e.target.value)}
|
||||||
|
className="h-9 focus-visible:ring-1"
|
||||||
|
autoFocus
|
||||||
|
placeholder="單位名稱"
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') saveEdit(unit.id);
|
||||||
|
if (e.key === 'Escape') cancelEdit();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span className="font-medium text-gray-700">{unit.name}</span>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="py-3">
|
||||||
|
{editingId === unit.id ? (
|
||||||
|
<Input
|
||||||
|
value={editCode}
|
||||||
|
onChange={(e) => setEditCode(e.target.value)}
|
||||||
|
className="h-9 focus-visible:ring-1"
|
||||||
|
placeholder="代碼"
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') saveEdit(unit.id);
|
||||||
|
if (e.key === 'Escape') cancelEdit();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span className="text-gray-500">{unit.code || '-'}</span>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right py-3">
|
||||||
|
{editingId === unit.id ? (
|
||||||
|
<div className="flex justify-end gap-1">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-8 w-8 text-green-600 hover:text-green-700 hover:bg-green-50"
|
||||||
|
onClick={() => saveEdit(unit.id)}
|
||||||
|
>
|
||||||
|
<Check className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-8 w-8 text-gray-400 hover:text-gray-600 hover:bg-gray-100"
|
||||||
|
onClick={cancelEdit}
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex justify-end gap-1">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-8 w-8 p-0 button-outlined-primary"
|
||||||
|
onClick={() => startEdit(unit)}
|
||||||
|
>
|
||||||
|
<Edit2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<AlertDialog>
|
||||||
|
<AlertDialogTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-8 w-8 p-0 button-outlined-error"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</AlertDialogTrigger>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>確認刪除單位</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
確定要刪除「{unit.name}」嗎?<br />
|
||||||
|
若該單位下仍有商品,系統將會拒絕刪除。
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>取消</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={() => handleDelete(unit.id)}
|
||||||
|
className="bg-red-600 hover:bg-red-700"
|
||||||
|
>
|
||||||
|
確認刪除
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2 pt-4 border-t mt-auto">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => onOpenChange(false)}
|
||||||
|
className="button-outlined-primary px-8"
|
||||||
|
>
|
||||||
|
關閉
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -53,8 +53,8 @@ export default function AddSupplyProductDialog({
|
|||||||
|
|
||||||
// 過濾掉已經在供貨列表中的商品
|
// 過濾掉已經在供貨列表中的商品
|
||||||
const availableProducts = useMemo(() => {
|
const availableProducts = useMemo(() => {
|
||||||
const existingIds = new Set(existingSupplyProducts.map(sp => sp.productId));
|
const existingIds = new Set(existingSupplyProducts.map(sp => String(sp.productId)));
|
||||||
return products.filter(p => !existingIds.has(p.id));
|
return products.filter(p => !existingIds.has(String(p.id)));
|
||||||
}, [products, existingSupplyProducts]);
|
}, [products, existingSupplyProducts]);
|
||||||
|
|
||||||
const selectedProduct = availableProducts.find(p => p.id === selectedProductId);
|
const selectedProduct = availableProducts.find(p => p.id === selectedProductId);
|
||||||
@@ -105,7 +105,7 @@ export default function AddSupplyProductDialog({
|
|||||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
</button>
|
</button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent className="w-[450px] p-0 shadow-lg border-2" align="start">
|
<PopoverContent className="w-[450px] p-0 shadow-lg border-2 z-[9999]" align="start">
|
||||||
<Command>
|
<Command>
|
||||||
<CommandInput placeholder="搜尋商品名稱..." />
|
<CommandInput placeholder="搜尋商品名稱..." />
|
||||||
<CommandList className="max-h-[300px]">
|
<CommandList className="max-h-[300px]">
|
||||||
@@ -132,7 +132,7 @@ export default function AddSupplyProductDialog({
|
|||||||
<div className="flex items-center justify-between flex-1">
|
<div className="flex items-center justify-between flex-1">
|
||||||
<span className="font-medium">{product.name}</span>
|
<span className="font-medium">{product.name}</span>
|
||||||
<span className="text-xs text-gray-400 bg-gray-50 px-2 py-1 rounded">
|
<span className="text-xs text-gray-400 bg-gray-50 px-2 py-1 rounded">
|
||||||
{product.purchase_unit || product.base_unit || "個"}
|
{product.baseUnit?.name || (product.base_unit as any)?.name || product.base_unit || "個"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</CommandItem>
|
</CommandItem>
|
||||||
@@ -146,15 +146,17 @@ export default function AddSupplyProductDialog({
|
|||||||
|
|
||||||
{/* 單位(自動帶入) */}
|
{/* 單位(自動帶入) */}
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<Label className="text-sm font-medium text-gray-500">採購單位</Label>
|
<Label className="text-sm font-medium text-gray-500">單位</Label>
|
||||||
<div className="h-10 px-3 py-2 bg-gray-50 border border-gray-200 rounded-md text-gray-600 font-medium text-sm flex items-center">
|
<div className="h-10 px-3 py-2 bg-gray-50 border border-gray-200 rounded-md text-gray-600 font-medium text-sm flex items-center">
|
||||||
{selectedProduct ? (selectedProduct.purchase_unit || selectedProduct.base_unit || "個") : "-"}
|
{selectedProduct ? (selectedProduct.baseUnit?.name || (selectedProduct.base_unit as any)?.name || selectedProduct.base_unit || "個") : "-"}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 上次採購價格 */}
|
{/* 上次採購價格 */}
|
||||||
<div>
|
<div>
|
||||||
<Label className="text-muted-foreground text-xs">上次採購單價(選填)</Label>
|
<Label className="text-muted-foreground text-xs">
|
||||||
|
上次採購單價 / {selectedProduct ? (selectedProduct.baseUnit?.name || (selectedProduct.base_unit as any)?.name || selectedProduct.base_unit || "個") : "單位"}
|
||||||
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
placeholder="輸入價格"
|
placeholder="輸入價格"
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ export default function EditSupplyProductDialog({
|
|||||||
|
|
||||||
{/* 上次採購價格 */}
|
{/* 上次採購價格 */}
|
||||||
<div>
|
<div>
|
||||||
<Label className="text-muted-foreground text-xs">上次採購單價(選填)</Label>
|
<Label className="text-muted-foreground text-xs">上次採購單價 / {product.baseUnit || "單位"}</Label>
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
placeholder="輸入價格"
|
placeholder="輸入價格"
|
||||||
|
|||||||
@@ -28,15 +28,19 @@ export default function SupplyProductList({
|
|||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead className="w-[50px] text-center">#</TableHead>
|
<TableHead className="w-[50px] text-center">#</TableHead>
|
||||||
<TableHead className="font-semibold">商品名稱</TableHead>
|
<TableHead className="font-semibold">商品名稱</TableHead>
|
||||||
<TableHead className="font-semibold">採購單位</TableHead>
|
<TableHead className="font-semibold">基本單位</TableHead>
|
||||||
<TableHead className="text-right font-semibold">上次採購單價</TableHead>
|
<TableHead className="font-semibold">轉換率</TableHead>
|
||||||
|
<TableHead className="text-right font-semibold">
|
||||||
|
上次採購單價
|
||||||
|
<div className="text-xs font-normal text-muted-foreground">(以基本單位計算)</div>
|
||||||
|
</TableHead>
|
||||||
<TableHead className="text-center font-semibold w-[150px]">操作</TableHead>
|
<TableHead className="text-center font-semibold w-[150px]">操作</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{products.length === 0 ? (
|
{products.length === 0 ? (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={5} className="text-center text-muted-foreground py-8">
|
<TableCell colSpan={6} className="text-center text-muted-foreground py-8">
|
||||||
尚無供貨商品,請點擊上方按鈕新增
|
尚無供貨商品,請點擊上方按鈕新增
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
@@ -47,9 +51,26 @@ export default function SupplyProductList({
|
|||||||
{index + 1}
|
{index + 1}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>{product.productName}</TableCell>
|
<TableCell>{product.productName}</TableCell>
|
||||||
<TableCell>{product.unit}</TableCell>
|
<TableCell>
|
||||||
|
{product.baseUnit || product.unit || "-"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{product.largeUnit && product.conversionRate ? (
|
||||||
|
<span className="text-sm text-gray-500">
|
||||||
|
1 {product.largeUnit} = {Number(product.conversionRate)} {product.baseUnit || product.unit}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
"-"
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
<TableCell className="text-right">
|
<TableCell className="text-right">
|
||||||
{product.lastPrice ? `$${product.lastPrice.toLocaleString()}` : "-"}
|
{product.lastPrice ? (
|
||||||
|
<span>
|
||||||
|
${product.lastPrice.toLocaleString()} / {product.baseUnit || product.unit || "單位"}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
"-"
|
||||||
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-center">
|
<TableCell className="text-center">
|
||||||
<div className="flex items-center justify-center gap-2">
|
<div className="flex items-center justify-center gap-2">
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { getCurrentDateTime, generateOrderNumber } from "@/utils/format";
|
import { getCurrentDateTime } from "@/utils/format";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -46,6 +46,7 @@ interface AvailableProduct {
|
|||||||
productName: string;
|
productName: string;
|
||||||
batchNumber: string;
|
batchNumber: string;
|
||||||
availableQty: number;
|
availableQty: number;
|
||||||
|
unit: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function TransferOrderDialog({
|
export default function TransferOrderDialog({
|
||||||
@@ -276,7 +277,7 @@ export default function TransferOrderDialog({
|
|||||||
value={`${product.productId}|||${product.batchNumber}`}
|
value={`${product.productId}|||${product.batchNumber}`}
|
||||||
>
|
>
|
||||||
{product.productName} (庫存:{" "}
|
{product.productName} (庫存:{" "}
|
||||||
{product.availableQty})
|
{product.availableQty} {product.unit})
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
@@ -303,7 +304,7 @@ export default function TransferOrderDialog({
|
|||||||
<div className="h-5">
|
<div className="h-5">
|
||||||
{selectedProduct && (
|
{selectedProduct && (
|
||||||
<p className="text-sm text-gray-500">
|
<p className="text-sm text-gray-500">
|
||||||
可用庫存: {selectedProduct.availableQty}
|
可用庫存: {selectedProduct.availableQty} {selectedProduct.unit}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -12,13 +12,20 @@ function AlertDialog({
|
|||||||
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />;
|
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function AlertDialogTrigger({
|
const AlertDialogTrigger = React.forwardRef<
|
||||||
...props
|
React.ComponentRef<typeof AlertDialogPrimitive.Trigger>,
|
||||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
|
React.ComponentProps<typeof AlertDialogPrimitive.Trigger>
|
||||||
|
>(({ className, ...props }, ref) => {
|
||||||
return (
|
return (
|
||||||
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
|
<AlertDialogPrimitive.Trigger
|
||||||
|
ref={ref}
|
||||||
|
data-slot="alert-dialog-trigger"
|
||||||
|
className={className}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
AlertDialogTrigger.displayName = AlertDialogPrimitive.Trigger.displayName;
|
||||||
|
|
||||||
function AlertDialogPortal({
|
function AlertDialogPortal({
|
||||||
...props
|
...props
|
||||||
@@ -28,119 +35,140 @@ function AlertDialogPortal({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function AlertDialogOverlay({
|
const AlertDialogOverlay = React.forwardRef<
|
||||||
className,
|
React.ComponentRef<typeof AlertDialogPrimitive.Overlay>,
|
||||||
...props
|
React.ComponentProps<typeof AlertDialogPrimitive.Overlay>
|
||||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
|
>(({ className, ...props }, ref) => {
|
||||||
return (
|
return (
|
||||||
<AlertDialogPrimitive.Overlay
|
<AlertDialogPrimitive.Overlay
|
||||||
|
ref={ref}
|
||||||
data-slot="alert-dialog-overlay"
|
data-slot="alert-dialog-overlay"
|
||||||
className={cn(
|
className={cn(
|
||||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-[150] bg-black/50",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName;
|
||||||
|
|
||||||
function AlertDialogContent({
|
const AlertDialogContent = React.forwardRef<
|
||||||
className,
|
React.ComponentRef<typeof AlertDialogPrimitive.Content>,
|
||||||
...props
|
React.ComponentProps<typeof AlertDialogPrimitive.Content>
|
||||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Content>) {
|
>(({ className, ...props }, ref) => {
|
||||||
return (
|
return (
|
||||||
<AlertDialogPortal>
|
<AlertDialogPortal>
|
||||||
<AlertDialogOverlay />
|
<AlertDialogOverlay />
|
||||||
<AlertDialogPrimitive.Content
|
<AlertDialogPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
data-slot="alert-dialog-content"
|
data-slot="alert-dialog-content"
|
||||||
className={cn(
|
className={cn(
|
||||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
|
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-[150] grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
</AlertDialogPortal>
|
</AlertDialogPortal>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName;
|
||||||
|
|
||||||
function AlertDialogHeader({
|
const AlertDialogHeader = React.forwardRef<
|
||||||
className,
|
HTMLDivElement,
|
||||||
...props
|
React.ComponentProps<"div">
|
||||||
}: React.ComponentProps<"div">) {
|
>(({ className, ...props }, ref) => (
|
||||||
return (
|
<div
|
||||||
<div
|
ref={ref}
|
||||||
data-slot="alert-dialog-header"
|
data-slot="alert-dialog-header"
|
||||||
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
));
|
||||||
}
|
AlertDialogHeader.displayName = "AlertDialogHeader";
|
||||||
|
|
||||||
function AlertDialogFooter({
|
const AlertDialogFooter = React.forwardRef<
|
||||||
className,
|
HTMLDivElement,
|
||||||
...props
|
React.ComponentProps<"div">
|
||||||
}: React.ComponentProps<"div">) {
|
>(({ className, ...props }, ref) => (
|
||||||
return (
|
<div
|
||||||
<div
|
ref={ref}
|
||||||
data-slot="alert-dialog-footer"
|
data-slot="alert-dialog-footer"
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
));
|
||||||
}
|
AlertDialogFooter.displayName = "AlertDialogFooter";
|
||||||
|
|
||||||
function AlertDialogTitle({
|
const AlertDialogTitle = React.forwardRef<
|
||||||
className,
|
React.ComponentRef<typeof AlertDialogPrimitive.Title>,
|
||||||
...props
|
React.ComponentProps<typeof AlertDialogPrimitive.Title>
|
||||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
|
>(({ className, ...props }, ref) => {
|
||||||
return (
|
return (
|
||||||
<AlertDialogPrimitive.Title
|
<AlertDialogPrimitive.Title
|
||||||
|
ref={ref}
|
||||||
data-slot="alert-dialog-title"
|
data-slot="alert-dialog-title"
|
||||||
className={cn("text-lg font-semibold", className)}
|
className={cn("text-lg font-semibold", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName;
|
||||||
|
|
||||||
function AlertDialogDescription({
|
const AlertDialogDescription = React.forwardRef<
|
||||||
className,
|
React.ComponentRef<typeof AlertDialogPrimitive.Description>,
|
||||||
...props
|
React.ComponentProps<typeof AlertDialogPrimitive.Description>
|
||||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
|
>(({ className, ...props }, ref) => {
|
||||||
return (
|
return (
|
||||||
<AlertDialogPrimitive.Description
|
<AlertDialogPrimitive.Description
|
||||||
|
ref={ref}
|
||||||
data-slot="alert-dialog-description"
|
data-slot="alert-dialog-description"
|
||||||
className={cn("text-muted-foreground text-sm", className)}
|
className={cn("text-muted-foreground text-sm", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
AlertDialogDescription.displayName =
|
||||||
|
AlertDialogPrimitive.Description.displayName;
|
||||||
|
|
||||||
function AlertDialogAction({
|
const AlertDialogAction = React.forwardRef<
|
||||||
className,
|
React.ComponentRef<typeof AlertDialogPrimitive.Action>,
|
||||||
...props
|
React.ComponentProps<typeof AlertDialogPrimitive.Action>
|
||||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Action>) {
|
>(({ className, ...props }, ref) => {
|
||||||
return (
|
return (
|
||||||
<AlertDialogPrimitive.Action
|
<AlertDialogPrimitive.Action
|
||||||
className={cn(buttonVariants(), "bg-red-600 hover:bg-red-700 text-white border-transparent", className)}
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
buttonVariants(),
|
||||||
|
"bg-red-600 hover:bg-red-700 text-white border-transparent",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName;
|
||||||
|
|
||||||
function AlertDialogCancel({
|
const AlertDialogCancel = React.forwardRef<
|
||||||
className,
|
React.ComponentRef<typeof AlertDialogPrimitive.Cancel>,
|
||||||
...props
|
React.ComponentProps<typeof AlertDialogPrimitive.Cancel>
|
||||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel>) {
|
>(({ className, ...props }, ref) => {
|
||||||
return (
|
return (
|
||||||
<AlertDialogPrimitive.Cancel
|
<AlertDialogPrimitive.Cancel
|
||||||
className={cn(buttonVariants({ variant: "outline" }), "button-outlined-primary mt-0", className)}
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
buttonVariants({ variant: "outline" }),
|
||||||
|
"button-outlined-primary mt-0",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName;
|
||||||
|
|
||||||
export {
|
export {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||||
import { XIcon } from "lucide-react";
|
import { X } from "lucide-react";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
@@ -12,11 +12,20 @@ function Dialog({
|
|||||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
|
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function DialogTrigger({
|
const DialogTrigger = React.forwardRef<
|
||||||
...props
|
React.ComponentRef<typeof DialogPrimitive.Trigger>,
|
||||||
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
React.ComponentProps<typeof DialogPrimitive.Trigger>
|
||||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
|
>(({ className, ...props }, ref) => {
|
||||||
}
|
return (
|
||||||
|
<DialogPrimitive.Trigger
|
||||||
|
ref={ref}
|
||||||
|
data-slot="dialog-trigger"
|
||||||
|
className={className}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
DialogTrigger.displayName = DialogPrimitive.Trigger.displayName;
|
||||||
|
|
||||||
function DialogPortal({
|
function DialogPortal({
|
||||||
...props
|
...props
|
||||||
@@ -33,96 +42,98 @@ function DialogClose({
|
|||||||
const DialogOverlay = React.forwardRef<
|
const DialogOverlay = React.forwardRef<
|
||||||
React.ComponentRef<typeof DialogPrimitive.Overlay>,
|
React.ComponentRef<typeof DialogPrimitive.Overlay>,
|
||||||
React.ComponentProps<typeof DialogPrimitive.Overlay>
|
React.ComponentProps<typeof DialogPrimitive.Overlay>
|
||||||
>(({ className, ...props }, ref) => {
|
>(({ className, ...props }, ref) => (
|
||||||
return (
|
<DialogPrimitive.Overlay
|
||||||
<DialogPrimitive.Overlay
|
ref={ref}
|
||||||
ref={ref}
|
data-slot="dialog-overlay"
|
||||||
data-slot="dialog-overlay"
|
className={cn(
|
||||||
className={cn(
|
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-[100] bg-black/50",
|
||||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
className,
|
||||||
className,
|
)}
|
||||||
)}
|
{...props}
|
||||||
{...props}
|
/>
|
||||||
/>
|
));
|
||||||
);
|
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
||||||
});
|
|
||||||
DialogOverlay.displayName = "DialogOverlay";
|
|
||||||
|
|
||||||
const DialogContent = React.forwardRef<
|
const DialogContent = React.forwardRef<
|
||||||
React.ComponentRef<typeof DialogPrimitive.Content>,
|
React.ComponentRef<typeof DialogPrimitive.Content>,
|
||||||
React.ComponentProps<typeof DialogPrimitive.Content>
|
React.ComponentProps<typeof DialogPrimitive.Content>
|
||||||
>(({ className, children, ...props }, ref) => {
|
>(({ className, children, ...props }, ref) => (
|
||||||
return (
|
<DialogPortal>
|
||||||
<DialogPortal data-slot="dialog-portal">
|
<DialogOverlay />
|
||||||
<DialogOverlay />
|
<DialogPrimitive.Content
|
||||||
<DialogPrimitive.Content
|
ref={ref}
|
||||||
ref={ref}
|
data-slot="dialog-content"
|
||||||
data-slot="dialog-content"
|
|
||||||
className={cn(
|
|
||||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
<DialogPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4">
|
|
||||||
<XIcon />
|
|
||||||
<span className="sr-only">Close</span>
|
|
||||||
</DialogPrimitive.Close>
|
|
||||||
</DialogPrimitive.Content>
|
|
||||||
</DialogPortal>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
DialogContent.displayName = "DialogContent";
|
|
||||||
|
|
||||||
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
data-slot="dialog-header"
|
|
||||||
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
data-slot="dialog-footer"
|
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-[100] grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
>
|
||||||
);
|
{children}
|
||||||
}
|
<DialogPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:outline-hidden focus:ring-2 focus:ring-offset-2 disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4">
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
<span className="sr-only">Close</span>
|
||||||
|
</DialogPrimitive.Close>
|
||||||
|
</DialogPrimitive.Content>
|
||||||
|
</DialogPortal>
|
||||||
|
));
|
||||||
|
DialogContent.displayName = DialogPrimitive.Content.displayName;
|
||||||
|
|
||||||
function DialogTitle({
|
const DialogHeader = React.forwardRef<
|
||||||
className,
|
HTMLDivElement,
|
||||||
...props
|
React.ComponentProps<"div">
|
||||||
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
>(({ className, ...props }, ref) => (
|
||||||
return (
|
<div
|
||||||
<DialogPrimitive.Title
|
ref={ref}
|
||||||
data-slot="dialog-title"
|
data-slot="dialog-header"
|
||||||
className={cn("text-lg leading-none font-semibold", className)}
|
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
));
|
||||||
}
|
DialogHeader.displayName = "DialogHeader";
|
||||||
|
|
||||||
function DialogDescription({
|
const DialogFooter = React.forwardRef<
|
||||||
className,
|
HTMLDivElement,
|
||||||
...props
|
React.ComponentProps<"div">
|
||||||
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
|
>(({ className, ...props }, ref) => (
|
||||||
return (
|
<div
|
||||||
<DialogPrimitive.Description
|
ref={ref}
|
||||||
data-slot="dialog-description"
|
data-slot="dialog-footer"
|
||||||
className={cn("text-muted-foreground text-sm", className)}
|
className={cn(
|
||||||
{...props}
|
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||||
/>
|
className,
|
||||||
);
|
)}
|
||||||
}
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
DialogFooter.displayName = "DialogFooter";
|
||||||
|
|
||||||
|
const DialogTitle = React.forwardRef<
|
||||||
|
React.ComponentRef<typeof DialogPrimitive.Title>,
|
||||||
|
React.ComponentProps<typeof DialogPrimitive.Title>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DialogPrimitive.Title
|
||||||
|
ref={ref}
|
||||||
|
data-slot="dialog-title"
|
||||||
|
className={cn("text-lg font-semibold tracking-tight", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
DialogTitle.displayName = DialogPrimitive.Title.displayName;
|
||||||
|
|
||||||
|
const DialogDescription = React.forwardRef<
|
||||||
|
React.ComponentRef<typeof DialogPrimitive.Description>,
|
||||||
|
React.ComponentProps<typeof DialogPrimitive.Description>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DialogPrimitive.Description
|
||||||
|
ref={ref}
|
||||||
|
data-slot="dialog-description"
|
||||||
|
className={cn("text-muted-foreground text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
DialogDescription.displayName = DialogPrimitive.Description.displayName;
|
||||||
|
|
||||||
export {
|
export {
|
||||||
Dialog,
|
Dialog,
|
||||||
|
|||||||
@@ -82,9 +82,9 @@ function SelectContent({
|
|||||||
<SelectPrimitive.Content
|
<SelectPrimitive.Content
|
||||||
data-slot="select-content"
|
data-slot="select-content"
|
||||||
className={cn(
|
className={cn(
|
||||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
|
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-[150] max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
|
||||||
position === "popper" &&
|
position === "popper" &&
|
||||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
position={position}
|
position={position}
|
||||||
@@ -95,7 +95,7 @@ function SelectContent({
|
|||||||
className={cn(
|
className={cn(
|
||||||
"p-1",
|
"p-1",
|
||||||
position === "popper" &&
|
position === "popper" &&
|
||||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1",
|
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import {
|
|||||||
User,
|
User,
|
||||||
ChevronDown
|
ChevronDown
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Toaster } from "sonner";
|
import { toast, Toaster } from "sonner";
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { Link, usePage } from "@inertiajs/react";
|
import { Link, usePage } from "@inertiajs/react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
@@ -139,6 +139,20 @@ export default function AuthenticatedLayout({
|
|||||||
localStorage.setItem("sidebar-collapsed", String(isCollapsed));
|
localStorage.setItem("sidebar-collapsed", String(isCollapsed));
|
||||||
}, [isCollapsed]);
|
}, [isCollapsed]);
|
||||||
|
|
||||||
|
// 全域監聽 flash 訊息並顯示 Toast
|
||||||
|
useEffect(() => {
|
||||||
|
// @ts-ignore
|
||||||
|
if (props.flash?.success) {
|
||||||
|
// @ts-ignore
|
||||||
|
toast.success(props.flash.success);
|
||||||
|
}
|
||||||
|
// @ts-ignore
|
||||||
|
if (props.flash?.error) {
|
||||||
|
// @ts-ignore
|
||||||
|
toast.error(props.flash.error);
|
||||||
|
}
|
||||||
|
}, [props.flash]);
|
||||||
|
|
||||||
const toggleExpand = (itemId: string) => {
|
const toggleExpand = (itemId: string) => {
|
||||||
if (isCollapsed) {
|
if (isCollapsed) {
|
||||||
setIsCollapsed(false);
|
setIsCollapsed(false);
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { Plus, Search, X } from "lucide-react";
|
|||||||
import ProductTable from "@/Components/Product/ProductTable";
|
import ProductTable from "@/Components/Product/ProductTable";
|
||||||
import ProductDialog from "@/Components/Product/ProductDialog";
|
import ProductDialog from "@/Components/Product/ProductDialog";
|
||||||
import CategoryManagerDialog from "@/Components/Category/CategoryManagerDialog";
|
import CategoryManagerDialog from "@/Components/Category/CategoryManagerDialog";
|
||||||
|
import UnitManagerDialog, { Unit } from "@/Components/Unit/UnitManagerDialog";
|
||||||
import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout";
|
import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout";
|
||||||
import { Head, router } from "@inertiajs/react";
|
import { Head, router } from "@inertiajs/react";
|
||||||
import { debounce } from "lodash";
|
import { debounce } from "lodash";
|
||||||
@@ -31,10 +32,13 @@ export interface Product {
|
|||||||
category?: Category;
|
category?: Category;
|
||||||
brand?: string;
|
brand?: string;
|
||||||
specification?: string;
|
specification?: string;
|
||||||
base_unit: string;
|
base_unit_id: number;
|
||||||
large_unit?: string;
|
base_unit?: Unit;
|
||||||
|
large_unit_id?: number;
|
||||||
|
large_unit?: Unit;
|
||||||
conversion_rate?: number;
|
conversion_rate?: number;
|
||||||
purchase_unit?: string;
|
purchase_unit_id?: number;
|
||||||
|
purchase_unit?: Unit;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
}
|
}
|
||||||
@@ -46,6 +50,7 @@ interface PageProps {
|
|||||||
from: number;
|
from: number;
|
||||||
};
|
};
|
||||||
categories: Category[];
|
categories: Category[];
|
||||||
|
units: Unit[];
|
||||||
filters: {
|
filters: {
|
||||||
search?: string;
|
search?: string;
|
||||||
category_id?: string;
|
category_id?: string;
|
||||||
@@ -55,7 +60,7 @@ interface PageProps {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ProductManagement({ products, categories, filters }: PageProps) {
|
export default function ProductManagement({ products, categories, units, filters }: PageProps) {
|
||||||
const [searchTerm, setSearchTerm] = useState(filters.search || "");
|
const [searchTerm, setSearchTerm] = useState(filters.search || "");
|
||||||
const [typeFilter, setTypeFilter] = useState<string>(filters.category_id || "all");
|
const [typeFilter, setTypeFilter] = useState<string>(filters.category_id || "all");
|
||||||
const [perPage, setPerPage] = useState<string>(filters.per_page || "10");
|
const [perPage, setPerPage] = useState<string>(filters.per_page || "10");
|
||||||
@@ -63,6 +68,7 @@ export default function ProductManagement({ products, categories, filters }: Pag
|
|||||||
const [sortDirection, setSortDirection] = useState<"asc" | "desc" | null>(filters.sort_direction as "asc" | "desc" || null);
|
const [sortDirection, setSortDirection] = useState<"asc" | "desc" | null>(filters.sort_direction as "asc" | "desc" || null);
|
||||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||||
const [isCategoryDialogOpen, setIsCategoryDialogOpen] = useState(false);
|
const [isCategoryDialogOpen, setIsCategoryDialogOpen] = useState(false);
|
||||||
|
const [isUnitDialogOpen, setIsUnitDialogOpen] = useState(false);
|
||||||
const [editingProduct, setEditingProduct] = useState<Product | null>(null);
|
const [editingProduct, setEditingProduct] = useState<Product | null>(null);
|
||||||
|
|
||||||
// Sync state with props when they change (e.g. navigation)
|
// Sync state with props when they change (e.g. navigation)
|
||||||
@@ -163,13 +169,11 @@ export default function ProductManagement({ products, categories, filters }: Pag
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteProduct = (id: number) => {
|
const handleDeleteProduct = (id: number) => {
|
||||||
if (confirm("確定要刪除嗎?")) {
|
router.delete(route('products.destroy', id), {
|
||||||
router.delete(route('products.destroy', id), {
|
onSuccess: () => {
|
||||||
onSuccess: () => {
|
// Toast handled by flash message
|
||||||
// Toast handled by flash message usually, or add here if needed
|
}
|
||||||
}
|
});
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -226,6 +230,13 @@ export default function ProductManagement({ products, categories, filters }: Pag
|
|||||||
>
|
>
|
||||||
管理分類
|
管理分類
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setIsUnitDialogOpen(true)}
|
||||||
|
className="flex-1 md:flex-none button-outlined-primary"
|
||||||
|
>
|
||||||
|
管理單位
|
||||||
|
</Button>
|
||||||
<Button onClick={handleAddProduct} className="flex-1 md:flex-none button-filled-primary">
|
<Button onClick={handleAddProduct} className="flex-1 md:flex-none button-filled-primary">
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
新增商品
|
新增商品
|
||||||
@@ -270,6 +281,7 @@ export default function ProductManagement({ products, categories, filters }: Pag
|
|||||||
onOpenChange={setIsDialogOpen}
|
onOpenChange={setIsDialogOpen}
|
||||||
product={editingProduct}
|
product={editingProduct}
|
||||||
categories={categories}
|
categories={categories}
|
||||||
|
units={units}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<CategoryManagerDialog
|
<CategoryManagerDialog
|
||||||
@@ -277,6 +289,12 @@ export default function ProductManagement({ products, categories, filters }: Pag
|
|||||||
onOpenChange={setIsCategoryDialogOpen}
|
onOpenChange={setIsCategoryDialogOpen}
|
||||||
categories={categories}
|
categories={categories}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<UnitManagerDialog
|
||||||
|
open={isUnitDialogOpen}
|
||||||
|
onOpenChange={setIsUnitDialogOpen}
|
||||||
|
units={units}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</AuthenticatedLayout>
|
</AuthenticatedLayout>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { Head, Link } from "@inertiajs/react";
|
|||||||
import { StatusProgressBar } from "@/Components/PurchaseOrder/StatusProgressBar";
|
import { StatusProgressBar } from "@/Components/PurchaseOrder/StatusProgressBar";
|
||||||
import PurchaseOrderStatusBadge from "@/Components/PurchaseOrder/PurchaseOrderStatusBadge";
|
import PurchaseOrderStatusBadge from "@/Components/PurchaseOrder/PurchaseOrderStatusBadge";
|
||||||
import CopyButton from "@/Components/shared/CopyButton";
|
import CopyButton from "@/Components/shared/CopyButton";
|
||||||
|
import { PurchaseOrderItemsTable } from "@/Components/PurchaseOrder/PurchaseOrderItemsTable";
|
||||||
import type { PurchaseOrder } from "@/types/purchase-order";
|
import type { PurchaseOrder } from "@/types/purchase-order";
|
||||||
import { formatCurrency, formatDateTime } from "@/utils/format";
|
import { formatCurrency, formatDateTime } from "@/utils/format";
|
||||||
import { getShowBreadcrumbs } from "@/utils/breadcrumb";
|
import { getShowBreadcrumbs } from "@/utils/breadcrumb";
|
||||||
@@ -104,66 +105,17 @@ export default function ViewPurchaseOrderPage({ order }: Props) {
|
|||||||
<div className="p-6 border-b border-gray-100">
|
<div className="p-6 border-b border-gray-100">
|
||||||
<h2 className="text-lg font-bold text-gray-900">採購項目清單</h2>
|
<h2 className="text-lg font-bold text-gray-900">採購項目清單</h2>
|
||||||
</div>
|
</div>
|
||||||
<div className="overflow-x-auto">
|
<div className="p-6">
|
||||||
<table className="w-full">
|
<PurchaseOrderItemsTable
|
||||||
<thead>
|
items={order.items}
|
||||||
<tr className="bg-gray-50/50">
|
isReadOnly={true}
|
||||||
<th className="text-left py-4 px-6 text-xs font-semibold text-gray-500 uppercase tracking-wider w-[50px]">
|
/>
|
||||||
#
|
<div className="mt-4 flex justify-end items-center gap-4 border-t pt-4">
|
||||||
</th>
|
<span className="text-gray-600 font-medium">總金額</span>
|
||||||
<th className="text-left py-4 px-6 text-xs font-semibold text-gray-500 uppercase tracking-wider">
|
<span className="text-xl font-bold text-primary">
|
||||||
商品名稱
|
{formatCurrency(order.totalAmount)}
|
||||||
</th>
|
</span>
|
||||||
<th className="text-right py-4 px-6 text-xs font-semibold text-gray-500 uppercase tracking-wider">
|
</div>
|
||||||
單價
|
|
||||||
</th>
|
|
||||||
<th className="text-right py-4 px-6 text-xs font-semibold text-gray-500 uppercase tracking-wider w-32">
|
|
||||||
數量
|
|
||||||
</th>
|
|
||||||
<th className="text-right py-4 px-6 text-xs font-semibold text-gray-500 uppercase tracking-wider">
|
|
||||||
小計
|
|
||||||
</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="divide-y divide-gray-100">
|
|
||||||
{order.items.map((item, index) => (
|
|
||||||
<tr key={index} className="hover:bg-gray-50/30 transition-colors">
|
|
||||||
<td className="py-4 px-6 text-gray-500 font-medium text-center">
|
|
||||||
{index + 1}
|
|
||||||
</td>
|
|
||||||
<td className="py-4 px-6">
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<span className="font-medium text-gray-900">{item.productName}</span>
|
|
||||||
<span className="text-xs text-gray-400">ID: {item.productId}</span>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="py-4 px-6 text-right">
|
|
||||||
<div className="flex flex-col items-end">
|
|
||||||
<span className="text-gray-900">{formatCurrency(item.unitPrice)}</span>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="py-4 px-6 text-right">
|
|
||||||
<span className="text-gray-900 font-medium">
|
|
||||||
{item.quantity} {item.unit}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td className="py-4 px-6 text-right font-bold text-gray-900">
|
|
||||||
{formatCurrency(item.subtotal)}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
<tfoot className="bg-gray-50/50 border-t border-gray-100">
|
|
||||||
<tr>
|
|
||||||
<td colSpan={4} className="py-5 px-6 text-right font-medium text-gray-600">
|
|
||||||
總金額
|
|
||||||
</td>
|
|
||||||
<td className="py-5 px-6 text-right font-bold text-xl text-primary">
|
|
||||||
{formatCurrency(order.totalAmount)}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tfoot>
|
|
||||||
</table>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
38
resources/js/Pages/Vendor/Show.tsx
vendored
38
resources/js/Pages/Vendor/Show.tsx
vendored
@@ -33,8 +33,14 @@ interface VendorProduct {
|
|||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
unit?: string;
|
unit?: string;
|
||||||
base_unit?: string;
|
// Relations might be camelCase or snake_case depending on serialization settings
|
||||||
|
baseUnit?: { name: string };
|
||||||
|
base_unit?: { name: string };
|
||||||
|
largeUnit?: { name: string };
|
||||||
|
large_unit?: { name: string };
|
||||||
|
purchaseUnit?: string; // Note: if it's a relation it might be an object, but original code treated it as string
|
||||||
purchase_unit?: string;
|
purchase_unit?: string;
|
||||||
|
conversion_rate?: number;
|
||||||
pivot: Pivot;
|
pivot: Pivot;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,13 +60,29 @@ export default function VendorShow({ vendor, products }: ShowProps) {
|
|||||||
const [selectedProduct, setSelectedProduct] = useState<SupplyProduct | null>(null);
|
const [selectedProduct, setSelectedProduct] = useState<SupplyProduct | null>(null);
|
||||||
|
|
||||||
// 轉換後端資料格式為前端組件需要的格式
|
// 轉換後端資料格式為前端組件需要的格式
|
||||||
const supplyProducts: SupplyProduct[] = vendor.products.map(p => ({
|
const supplyProducts: SupplyProduct[] = vendor.products.map(p => {
|
||||||
id: String(p.id),
|
// Laravel load('relationName') usually results in camelCase key in JSON if method is camelCase
|
||||||
productId: String(p.id),
|
const baseUnitName = p.baseUnit?.name || p.base_unit?.name;
|
||||||
productName: p.name,
|
const largeUnitName = p.largeUnit?.name || p.large_unit?.name;
|
||||||
unit: p.purchase_unit || p.base_unit || "個",
|
|
||||||
lastPrice: p.pivot.last_price || undefined,
|
// Check purchase unit - seemingly originally a field string, but if relation, check if object
|
||||||
}));
|
// Assuming purchase_unit is a string field on product table here based on original code usage?
|
||||||
|
// Wait, original code usage: p.purchase_unit || ...
|
||||||
|
// In Product model: purchase_unit_id exists, purchaseUnit is relation.
|
||||||
|
// If p.purchase_unit was working before, it might be an attribute (accessors).
|
||||||
|
// Let's stick to safe access.
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: String(p.id),
|
||||||
|
productId: String(p.id),
|
||||||
|
productName: p.name,
|
||||||
|
unit: p.purchase_unit || baseUnitName || "個",
|
||||||
|
baseUnit: baseUnitName,
|
||||||
|
largeUnit: largeUnitName,
|
||||||
|
conversionRate: p.conversion_rate,
|
||||||
|
lastPrice: p.pivot.last_price || undefined,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
const handleAddProduct = (productId: string, lastPrice?: number) => {
|
const handleAddProduct = (productId: string, lastPrice?: number) => {
|
||||||
router.post(route('vendors.products.store', vendor.id), {
|
router.post(route('vendors.products.store', vendor.id), {
|
||||||
|
|||||||
@@ -33,7 +33,9 @@ import { getInventoryBreadcrumbs } from "@/utils/breadcrumb";
|
|||||||
interface Product {
|
interface Product {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
unit: string;
|
baseUnit: string;
|
||||||
|
largeUnit?: string;
|
||||||
|
conversionRate?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -58,13 +60,17 @@ export default function AddInventoryPage({ warehouse, products }: Props) {
|
|||||||
|
|
||||||
// 新增明細行
|
// 新增明細行
|
||||||
const handleAddItem = () => {
|
const handleAddItem = () => {
|
||||||
const defaultProduct = products.length > 0 ? products[0] : { id: "", name: "", unit: "kg" };
|
const defaultProduct = products.length > 0 ? products[0] : { id: "", name: "", baseUnit: "個" };
|
||||||
const newItem: InboundItem = {
|
const newItem: InboundItem = {
|
||||||
tempId: `temp-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
tempId: `temp-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||||||
productId: defaultProduct.id,
|
productId: defaultProduct.id,
|
||||||
productName: defaultProduct.name,
|
productName: defaultProduct.name,
|
||||||
quantity: 0,
|
quantity: 0,
|
||||||
unit: defaultProduct.unit,
|
unit: defaultProduct.baseUnit, // 僅用於顯示當前選擇單位的名稱
|
||||||
|
baseUnit: defaultProduct.baseUnit,
|
||||||
|
largeUnit: defaultProduct.largeUnit,
|
||||||
|
conversionRate: defaultProduct.conversionRate,
|
||||||
|
selectedUnit: 'base',
|
||||||
};
|
};
|
||||||
setItems([...items, newItem]);
|
setItems([...items, newItem]);
|
||||||
};
|
};
|
||||||
@@ -86,11 +92,16 @@ export default function AddInventoryPage({ warehouse, products }: Props) {
|
|||||||
// 處理商品變更
|
// 處理商品變更
|
||||||
const handleProductChange = (tempId: string, productId: string) => {
|
const handleProductChange = (tempId: string, productId: string) => {
|
||||||
const product = products.find((p) => p.id === productId);
|
const product = products.find((p) => p.id === productId);
|
||||||
|
|
||||||
if (product) {
|
if (product) {
|
||||||
handleUpdateItem(tempId, {
|
handleUpdateItem(tempId, {
|
||||||
productId,
|
productId,
|
||||||
productName: product.name,
|
productName: product.name,
|
||||||
unit: product.unit,
|
unit: product.baseUnit,
|
||||||
|
baseUnit: product.baseUnit,
|
||||||
|
largeUnit: product.largeUnit,
|
||||||
|
conversionRate: product.conversionRate,
|
||||||
|
selectedUnit: 'base',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -135,10 +146,17 @@ export default function AddInventoryPage({ warehouse, products }: Props) {
|
|||||||
inboundDate,
|
inboundDate,
|
||||||
reason,
|
reason,
|
||||||
notes,
|
notes,
|
||||||
items: items.map(item => ({
|
items: items.map(item => {
|
||||||
productId: item.productId,
|
// 如果選擇大單位,則換算為基本單位數量
|
||||||
quantity: item.quantity
|
const finalQuantity = item.selectedUnit === 'large' && item.conversionRate
|
||||||
}))
|
? item.quantity * item.conversionRate
|
||||||
|
: item.quantity;
|
||||||
|
|
||||||
|
return {
|
||||||
|
productId: item.productId,
|
||||||
|
quantity: finalQuantity
|
||||||
|
};
|
||||||
|
})
|
||||||
}, {
|
}, {
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast.success("庫存記錄已儲存");
|
toast.success("庫存記錄已儲存");
|
||||||
@@ -296,71 +314,106 @@ export default function AddInventoryPage({ warehouse, products }: Props) {
|
|||||||
數量 <span className="text-red-500">*</span>
|
數量 <span className="text-red-500">*</span>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead className="w-[100px]">單位</TableHead>
|
<TableHead className="w-[100px]">單位</TableHead>
|
||||||
|
<TableHead className="w-[150px]">轉換數量</TableHead>
|
||||||
{/* <TableHead className="w-[180px]">效期</TableHead>
|
{/* <TableHead className="w-[180px]">效期</TableHead>
|
||||||
<TableHead className="w-[220px]">進貨編號</TableHead> */}
|
<TableHead className="w-[220px]">進貨編號</TableHead> */}
|
||||||
<TableHead className="w-[60px]"></TableHead>
|
<TableHead className="w-[60px]"></TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{items.map((item, index) => (
|
{items.map((item, index) => {
|
||||||
<TableRow key={item.tempId}>
|
// 計算轉換數量
|
||||||
{/* 商品 */}
|
const convertedQuantity = item.selectedUnit === 'large' && item.conversionRate
|
||||||
<TableCell>
|
? item.quantity * item.conversionRate
|
||||||
<Select
|
: item.quantity;
|
||||||
value={item.productId}
|
|
||||||
onValueChange={(value) =>
|
|
||||||
handleProductChange(item.tempId, value)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="border-gray-300">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{products.map((product) => (
|
|
||||||
<SelectItem key={product.id} value={product.id}>
|
|
||||||
{product.name}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
{errors[`item-${index}-product`] && (
|
|
||||||
<p className="text-xs text-red-500 mt-1">
|
|
||||||
{errors[`item-${index}-product`]}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</TableCell>
|
|
||||||
|
|
||||||
{/* 數量 */}
|
return (
|
||||||
<TableCell>
|
<TableRow key={item.tempId}>
|
||||||
<Input
|
{/* 商品 */}
|
||||||
type="number"
|
<TableCell>
|
||||||
min="1"
|
<Select
|
||||||
value={item.quantity || ""}
|
value={item.productId}
|
||||||
onChange={(e) =>
|
onValueChange={(value) =>
|
||||||
handleUpdateItem(item.tempId, {
|
handleProductChange(item.tempId, value)
|
||||||
quantity: parseInt(e.target.value) || 0,
|
}
|
||||||
})
|
>
|
||||||
}
|
<SelectTrigger className="border-gray-300">
|
||||||
className="border-gray-300"
|
<SelectValue />
|
||||||
/>
|
</SelectTrigger>
|
||||||
{errors[`item-${index}-quantity`] && (
|
<SelectContent className="z-[9999]">
|
||||||
<p className="text-xs text-red-500 mt-1">
|
{products.map((product) => (
|
||||||
{errors[`item-${index}-quantity`]}
|
<SelectItem key={product.id} value={product.id}>
|
||||||
</p>
|
{product.name}
|
||||||
)}
|
</SelectItem>
|
||||||
</TableCell>
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
{errors[`item-${index}-product`] && (
|
||||||
|
<p className="text-xs text-red-500 mt-1">
|
||||||
|
{errors[`item-${index}-product`]}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
|
||||||
{/* 單位 */}
|
{/* 數量 */}
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Input
|
<Input
|
||||||
value={item.unit}
|
type="number"
|
||||||
disabled
|
min="1"
|
||||||
className="bg-gray-50 border-gray-200"
|
value={item.quantity || ""}
|
||||||
/>
|
onChange={(e) =>
|
||||||
</TableCell>
|
handleUpdateItem(item.tempId, {
|
||||||
|
quantity: parseFloat(e.target.value) || 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="border-gray-300"
|
||||||
|
/>
|
||||||
|
{errors[`item-${index}-quantity`] && (
|
||||||
|
<p className="text-xs text-red-500 mt-1">
|
||||||
|
{errors[`item-${index}-quantity`]}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
|
||||||
{/* 效期 */}
|
{/* 單位 */}
|
||||||
{/* <TableCell>
|
<TableCell>
|
||||||
|
{item.largeUnit ? (
|
||||||
|
<Select
|
||||||
|
value={item.selectedUnit}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
handleUpdateItem(item.tempId, {
|
||||||
|
selectedUnit: value as 'base' | 'large',
|
||||||
|
unit: value === 'base' ? item.baseUnit : item.largeUnit
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="border-gray-300">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent className="z-[9999]">
|
||||||
|
<SelectItem value="base">{item.baseUnit}</SelectItem>
|
||||||
|
<SelectItem value="large">{item.largeUnit}</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
) : (
|
||||||
|
<Input
|
||||||
|
value={item.baseUnit || "個"}
|
||||||
|
disabled
|
||||||
|
className="bg-gray-50 border-gray-200"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
|
||||||
|
{/* 轉換數量 */}
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex items-center text-gray-700 font-medium bg-gray-50 px-3 py-2 rounded-md border border-gray-200">
|
||||||
|
<span>{convertedQuantity}</span>
|
||||||
|
<span className="ml-1 text-gray-500 text-sm">{item.baseUnit || "個"}</span>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
|
||||||
|
{/* 效期 */}
|
||||||
|
{/* <TableCell>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Input
|
<Input
|
||||||
type="date"
|
type="date"
|
||||||
@@ -375,8 +428,8 @@ export default function AddInventoryPage({ warehouse, products }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
</TableCell> */}
|
</TableCell> */}
|
||||||
|
|
||||||
{/* 批號 */}
|
{/* 批號 */}
|
||||||
{/* <TableCell>
|
{/* <TableCell>
|
||||||
<Input
|
<Input
|
||||||
value={item.batchNumber}
|
value={item.batchNumber}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
@@ -392,20 +445,21 @@ export default function AddInventoryPage({ warehouse, products }: Props) {
|
|||||||
)}
|
)}
|
||||||
</TableCell> */}
|
</TableCell> */}
|
||||||
|
|
||||||
{/* 刪除按鈕 */}
|
{/* 刪除按鈕 */}
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
onClick={() => handleRemoveItem(item.tempId)}
|
onClick={() => handleRemoveItem(item.tempId)}
|
||||||
className="hover:bg-red-50 hover:text-red-600 h-8 w-8"
|
className="hover:bg-red-50 hover:text-red-600 h-8 w-8"
|
||||||
>
|
>
|
||||||
<Trash2 className="h-4 w-4" />
|
<Trash2 className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -78,9 +78,17 @@ export default function WarehouseIndex({ warehouses, filters }: PageProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteWarehouse = (id: string) => {
|
const handleDeleteWarehouse = (id: string) => {
|
||||||
if (confirm("確定要停用此倉庫嗎?\n注意:刪除倉庫將連帶刪除所有庫存與紀錄!")) {
|
router.delete(route('warehouses.destroy', id), {
|
||||||
router.delete(route('warehouses.destroy', id));
|
onSuccess: () => {
|
||||||
}
|
toast.success('倉庫已刪除');
|
||||||
|
setEditingWarehouse(null);
|
||||||
|
},
|
||||||
|
onError: (errors: any) => {
|
||||||
|
// If backend returns error bag or flash error
|
||||||
|
// Flash error is handled by AuthenticatedLayout usually via usePage props.
|
||||||
|
// But we can also check errors bag here if needed.
|
||||||
|
}
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAddTransferOrder = () => {
|
const handleAddTransferOrder = () => {
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ export default function WarehouseInventoryPage({
|
|||||||
|
|
||||||
// 導航至流動紀錄頁
|
// 導航至流動紀錄頁
|
||||||
const handleView = (inventoryId: string) => {
|
const handleView = (inventoryId: string) => {
|
||||||
router.visit(route('warehouses.inventory.history', { warehouse: warehouse.id, inventory: inventoryId }));
|
router.visit(route('warehouses.inventory.history', { warehouse: warehouse.id, inventoryId: inventoryId }));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
@@ -74,13 +74,17 @@ export default function WarehouseInventoryPage({
|
|||||||
const handleDelete = () => {
|
const handleDelete = () => {
|
||||||
if (!deleteId) return;
|
if (!deleteId) return;
|
||||||
|
|
||||||
router.delete(route("warehouses.inventory.destroy", { warehouse: warehouse.id, inventory: deleteId }), {
|
// 暫存 ID 以免在對話框關閉的瞬間 state 被清空
|
||||||
|
const idToDelete = deleteId;
|
||||||
|
|
||||||
|
router.delete(route("warehouses.inventory.destroy", { warehouse: warehouse.id, inventoryId: idToDelete }), {
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast.success("庫存記錄已刪除");
|
toast.success("庫存記錄已刪除");
|
||||||
setDeleteId(null);
|
setDeleteId(null);
|
||||||
},
|
},
|
||||||
onError: () => {
|
onError: () => {
|
||||||
toast.error("刪除失敗");
|
toast.error("刪除失敗");
|
||||||
|
// 保持對話框開啟以便重試,或根據需要關閉
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -112,7 +116,7 @@ export default function WarehouseInventoryPage({
|
|||||||
{/* 操作按鈕 (位於標題下方) */}
|
{/* 操作按鈕 (位於標題下方) */}
|
||||||
<div className="flex items-center gap-3 mb-6">
|
<div className="flex items-center gap-3 mb-6">
|
||||||
{/* 安全庫存設定按鈕 */}
|
{/* 安全庫存設定按鈕 */}
|
||||||
<Link href={`/warehouses/${warehouse.id}/safety-stock-settings`}>
|
<Link href={route('warehouses.safety-stock.index', warehouse.id)}>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="button-outlined-primary"
|
className="button-outlined-primary"
|
||||||
@@ -135,7 +139,7 @@ export default function WarehouseInventoryPage({
|
|||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{/* 新增庫存按鈕 */}
|
{/* 新增庫存按鈕 */}
|
||||||
<Link href={`/warehouses/${warehouse.id}/add-inventory`}>
|
<Link href={route('warehouses.inventory.create', warehouse.id)}>
|
||||||
<Button
|
<Button
|
||||||
className="button-filled-primary"
|
className="button-filled-primary"
|
||||||
>
|
>
|
||||||
@@ -163,9 +167,6 @@ export default function WarehouseInventoryPage({
|
|||||||
onDelete={confirmDelete}
|
onDelete={confirmDelete}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
{/* 刪除確認對話框 */}
|
|
||||||
<AlertDialog open={!!deleteId} onOpenChange={(open) => !open && setDeleteId(null)}>
|
<AlertDialog open={!!deleteId} onOpenChange={(open) => !open && setDeleteId(null)}>
|
||||||
<AlertDialogContent>
|
<AlertDialogContent>
|
||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
@@ -176,7 +177,12 @@ export default function WarehouseInventoryPage({
|
|||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
<AlertDialogFooter>
|
<AlertDialogFooter>
|
||||||
<AlertDialogCancel className="button-outlined-primary">取消</AlertDialogCancel>
|
<AlertDialogCancel className="button-outlined-primary">取消</AlertDialogCancel>
|
||||||
<AlertDialogAction onClick={handleDelete} className="bg-red-600 hover:bg-red-700 text-white">
|
<AlertDialogAction
|
||||||
|
onClick={(e) => {
|
||||||
|
handleDelete();
|
||||||
|
}}
|
||||||
|
className="bg-red-600 hover:bg-red-700 text-white"
|
||||||
|
>
|
||||||
確認刪除
|
確認刪除
|
||||||
</AlertDialogAction>
|
</AlertDialogAction>
|
||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
|
|||||||
@@ -52,10 +52,10 @@ export function usePurchaseOrderForm({ order, suppliers }: UsePurchaseOrderFormP
|
|||||||
{
|
{
|
||||||
productId: "",
|
productId: "",
|
||||||
productName: "",
|
productName: "",
|
||||||
quantity: 0,
|
quantity: 1,
|
||||||
unit: "",
|
|
||||||
unitPrice: 0,
|
unitPrice: 0,
|
||||||
subtotal: 0,
|
subtotal: 0,
|
||||||
|
selectedUnit: "base",
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
};
|
};
|
||||||
@@ -66,32 +66,60 @@ export function usePurchaseOrderForm({ order, suppliers }: UsePurchaseOrderFormP
|
|||||||
};
|
};
|
||||||
|
|
||||||
// 更新商品項目
|
// 更新商品項目
|
||||||
const updateItem = (index: number, field: keyof PurchaseOrderItem, value: string | number) => {
|
const updateItem = (index: number, field: keyof PurchaseOrderItem, value: any) => {
|
||||||
const newItems = [...items];
|
const newItems = [...items];
|
||||||
newItems[index] = { ...newItems[index], [field]: value };
|
const item = { ...newItems[index] };
|
||||||
|
|
||||||
// 當選擇商品時,自動填入商品資訊
|
|
||||||
if (field === "productId" && selectedSupplier) {
|
if (field === "productId" && selectedSupplier) {
|
||||||
|
// value is productId string
|
||||||
const product = selectedSupplier.commonProducts.find((p) => p.productId === value);
|
const product = selectedSupplier.commonProducts.find((p) => p.productId === value);
|
||||||
if (product) {
|
if (product) {
|
||||||
newItems[index].productName = product.productName;
|
// @ts-ignore
|
||||||
newItems[index].unit = product.unit;
|
item.productId = value;
|
||||||
newItems[index].base_unit = product.base_unit;
|
item.productName = product.productName;
|
||||||
newItems[index].purchase_unit = product.purchase_unit;
|
item.base_unit_id = product.base_unit_id;
|
||||||
newItems[index].conversion_rate = product.conversion_rate;
|
item.base_unit_name = product.base_unit_name;
|
||||||
newItems[index].unitPrice = product.lastPrice;
|
item.large_unit_id = product.large_unit_id;
|
||||||
newItems[index].previousPrice = product.lastPrice;
|
item.large_unit_name = product.large_unit_name;
|
||||||
|
item.purchase_unit_id = product.purchase_unit_id;
|
||||||
|
item.conversion_rate = product.conversion_rate;
|
||||||
|
item.unitPrice = product.lastPrice;
|
||||||
|
item.previousPrice = product.lastPrice;
|
||||||
|
|
||||||
|
// 決定預設單位
|
||||||
|
// 若有採購單位且等於大單位,預設為大單位
|
||||||
|
const isPurchaseUnitLarge = product.purchase_unit_id && product.large_unit_id && product.purchase_unit_id === product.large_unit_id;
|
||||||
|
|
||||||
|
if (isPurchaseUnitLarge) {
|
||||||
|
item.selectedUnit = 'large';
|
||||||
|
item.unitId = product.large_unit_id;
|
||||||
|
} else {
|
||||||
|
item.selectedUnit = 'base';
|
||||||
|
item.unitId = product.base_unit_id;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
} else if (field === "selectedUnit") {
|
||||||
|
// @ts-ignore
|
||||||
|
item.selectedUnit = value;
|
||||||
|
if (value === 'large') {
|
||||||
|
item.unitId = item.large_unit_id;
|
||||||
|
} else {
|
||||||
|
item.unitId = item.base_unit_id;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// @ts-ignore
|
||||||
|
item[field] = value;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 計算小計
|
// 計算小計
|
||||||
if (field === "quantity" || field === "unitPrice") {
|
if (field === "quantity" || field === "unitPrice" || field === "productId") {
|
||||||
newItems[index].subtotal = calculateSubtotal(
|
item.subtotal = calculateSubtotal(
|
||||||
Number(newItems[index].quantity),
|
Number(item.quantity),
|
||||||
Number(newItems[index].unitPrice)
|
Number(item.unitPrice)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
newItems[index] = item;
|
||||||
setItems(newItems);
|
setItems(newItems);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -14,4 +14,5 @@ export interface Product {
|
|||||||
conversion_rate?: number;
|
conversion_rate?: number;
|
||||||
purchase_unit?: string;
|
purchase_unit?: string;
|
||||||
unit?: string; // 相容舊有程式碼
|
unit?: string; // 相容舊有程式碼
|
||||||
|
baseUnit?: { name: string };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,10 +21,14 @@ export interface PurchaseOrderItem {
|
|||||||
productId: string;
|
productId: string;
|
||||||
productName: string;
|
productName: string;
|
||||||
quantity: number;
|
quantity: number;
|
||||||
unit: string;
|
unitId?: number; // 選擇的單位ID
|
||||||
base_unit?: string; // 基本庫存單位
|
base_unit_id?: number;
|
||||||
purchase_unit?: string; // 採購單位
|
base_unit_name?: string;
|
||||||
conversion_rate?: number;// 換算率
|
large_unit_id?: number;
|
||||||
|
large_unit_name?: string;
|
||||||
|
purchase_unit_id?: number;
|
||||||
|
conversion_rate?: number;
|
||||||
|
selectedUnit?: 'base' | 'large'; // 前端狀態輔助
|
||||||
unitPrice: number;
|
unitPrice: number;
|
||||||
previousPrice?: number;
|
previousPrice?: number;
|
||||||
subtotal: number;
|
subtotal: number;
|
||||||
@@ -79,9 +83,11 @@ export interface PurchaseOrder {
|
|||||||
export interface CommonProduct {
|
export interface CommonProduct {
|
||||||
productId: string;
|
productId: string;
|
||||||
productName: string;
|
productName: string;
|
||||||
unit: string;
|
base_unit_id?: number;
|
||||||
base_unit?: string;
|
base_unit_name?: string;
|
||||||
purchase_unit?: string;
|
large_unit_id?: number;
|
||||||
|
large_unit_name?: string;
|
||||||
|
purchase_unit_id?: number;
|
||||||
conversion_rate?: number;
|
conversion_rate?: number;
|
||||||
lastPrice: number;
|
lastPrice: number;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ export interface SupplyProduct {
|
|||||||
productId: string;
|
productId: string;
|
||||||
productName: string;
|
productName: string;
|
||||||
unit: string;
|
unit: string;
|
||||||
|
baseUnit?: string;
|
||||||
|
largeUnit?: string;
|
||||||
|
conversionRate?: number;
|
||||||
lastPrice?: number;
|
lastPrice?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -147,6 +147,10 @@ export interface InboundItem {
|
|||||||
productName: string;
|
productName: string;
|
||||||
quantity: number;
|
quantity: number;
|
||||||
unit: string;
|
unit: string;
|
||||||
|
baseUnit?: string;
|
||||||
|
largeUnit?: string;
|
||||||
|
conversionRate?: number;
|
||||||
|
selectedUnit?: 'base' | 'large';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ use App\Http\Controllers\WarehouseController;
|
|||||||
use App\Http\Controllers\InventoryController;
|
use App\Http\Controllers\InventoryController;
|
||||||
use App\Http\Controllers\SafetyStockController;
|
use App\Http\Controllers\SafetyStockController;
|
||||||
use App\Http\Controllers\TransferOrderController;
|
use App\Http\Controllers\TransferOrderController;
|
||||||
|
use App\Http\Controllers\UnitController;
|
||||||
|
|
||||||
Route::get('/login', [LoginController::class, 'show'])->name('login');
|
Route::get('/login', [LoginController::class, 'show'])->name('login');
|
||||||
Route::post('/login', [LoginController::class, 'store']);
|
Route::post('/login', [LoginController::class, 'store']);
|
||||||
@@ -27,10 +28,16 @@ Route::middleware('auth')->group(function () {
|
|||||||
Route::put('/categories/{category}', [CategoryController::class, 'update'])->name('categories.update');
|
Route::put('/categories/{category}', [CategoryController::class, 'update'])->name('categories.update');
|
||||||
Route::delete('/categories/{category}', [CategoryController::class, 'destroy'])->name('categories.destroy');
|
Route::delete('/categories/{category}', [CategoryController::class, 'destroy'])->name('categories.destroy');
|
||||||
|
|
||||||
|
// 單位管理
|
||||||
|
Route::post('/units', [UnitController::class, 'store'])->name('units.store');
|
||||||
|
Route::put('/units/{unit}', [UnitController::class, 'update'])->name('units.update');
|
||||||
|
Route::delete('/units/{unit}', [UnitController::class, 'destroy'])->name('units.destroy');
|
||||||
|
|
||||||
// 商品管理
|
// 商品管理
|
||||||
Route::get('/products', [ProductController::class, 'index'])->name('products.index');
|
Route::get('/products', [ProductController::class, 'index'])->name('products.index');
|
||||||
Route::post('/products', [ProductController::class, 'store'])->name('products.store');
|
Route::post('/products', [ProductController::class, 'store'])->name('products.store');
|
||||||
Route::put('/products/{product}', [ProductController::class, 'update'])->name('products.update');
|
Route::put('/products/{product}', [ProductController::class, 'update'])->name('products.update');
|
||||||
|
Route::delete('/products/{product}', [ProductController::class, 'destroy'])->name('products.destroy');
|
||||||
|
|
||||||
// 廠商管理
|
// 廠商管理
|
||||||
Route::get('/vendors', [VendorController::class, 'index'])->name('vendors.index');
|
Route::get('/vendors', [VendorController::class, 'index'])->name('vendors.index');
|
||||||
|
|||||||
Reference in New Issue
Block a user