From 3ce96537b36d7d95c3932244e50a76ba07f8fbb3 Mon Sep 17 00:00:00 2001 From: sky121113 Date: Thu, 5 Feb 2026 11:45:08 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=A8=99=E6=BA=96=E5=8C=96=E5=85=A8?= =?UTF-8?q?=E7=B3=BB=E7=B5=B1=E6=95=B8=E5=80=BC=E8=BC=B8=E5=85=A5=E6=AC=84?= =?UTF-8?q?=E4=BD=8D=E8=88=87=E6=93=B4=E5=85=85=E5=95=86=E5=93=81=E5=83=B9?= =?UTF-8?q?=E6=A0=BC=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. UI 標準化: - 針對全系統數值輸入欄位統一加上 step='any' 以支援小數點。 - 表格形式 (Table) 的數值輸入欄位統一加上 text-right 靠右對齊。 - 修正 Components 與 Pages 中所有涉及金額與數量的輸入框。 2. 功能擴充與修正: - 擴充 Product 模型與相關 Dialog 以支援多種價格設定。 - 修正 Inventory/GoodsReceipt/Create.tsx 未使用的變數錯誤。 - 優化庫存相關頁面的 UI 一致性。 3. 其他: - 更新相關的 Type 定義與 Controller 邏輯。 --- .agent/skills/ui-consistency/SKILL.md | 37 ++- .../Controllers/AdjustDocController.php | 14 +- .../Controllers/CountDocController.php | 16 +- .../Controllers/InventoryController.php | 4 +- .../Controllers/ProductController.php | 30 +++ .../Controllers/TransferOrderController.php | 1 + .../Exports/ProductTemplateExport.php | 6 +- .../Inventory/Imports/ProductImport.php | 8 + app/Modules/Inventory/Models/Product.php | 4 + bootstrap/app.php | 23 +- ...05_103858_add_prices_to_products_table.php | 31 +++ .../js/Components/Inventory/ScannerInput.tsx | 161 ++++++++++++ .../js/Components/Product/ProductDialog.tsx | 76 +++++- .../PurchaseOrder/PurchaseOrderItemsTable.tsx | 10 +- .../SafetyStock/EditSafetyStockDialog.tsx | 1 + .../UtilityFee/UtilityFeeDialog.tsx | 2 +- .../Vendor/AddSupplyProductDialog.tsx | 2 + .../Vendor/EditSupplyProductDialog.tsx | 2 + .../Inventory/BatchAdjustmentModal.tsx | 2 +- .../Inventory/InventoryAdjustmentDialog.tsx | 2 +- .../Warehouse/Inventory/InventoryTable.tsx | 43 +--- .../SafetyStock/AddSafetyStockDialog.tsx | 2 +- .../SafetyStock/EditSafetyStockDialog.tsx | 2 +- .../js/Components/ui/searchable-select.tsx | 2 +- resources/js/Pages/Error/403.tsx | 36 --- resources/js/Pages/Error/Index.tsx | 99 ++++++++ resources/js/Pages/Inventory/Adjust/Show.tsx | 28 ++- resources/js/Pages/Inventory/Count/Show.tsx | 13 +- .../Pages/Inventory/GoodsReceipt/Create.tsx | 23 +- .../js/Pages/Inventory/Transfer/Show.tsx | 22 +- resources/js/Pages/Product/Index.tsx | 4 + resources/js/Pages/Production/Create.tsx | 6 +- resources/js/Pages/Production/Edit.tsx | 6 +- .../js/Pages/Production/Recipe/Create.tsx | 4 +- resources/js/Pages/Production/Recipe/Edit.tsx | 4 +- resources/js/Pages/PurchaseOrder/Create.tsx | 3 +- resources/js/Pages/ShippingOrder/Create.tsx | 10 +- resources/js/Pages/Warehouse/AddInventory.tsx | 233 +++++++++++++----- .../js/Pages/Warehouse/EditInventory.tsx | 2 +- resources/js/Pages/Warehouse/Inventory.tsx | 12 +- 40 files changed, 774 insertions(+), 212 deletions(-) create mode 100644 database/migrations/tenant/2026_02_05_103858_add_prices_to_products_table.php create mode 100644 resources/js/Components/Inventory/ScannerInput.tsx delete mode 100644 resources/js/Pages/Error/403.tsx create mode 100644 resources/js/Pages/Error/Index.tsx diff --git a/.agent/skills/ui-consistency/SKILL.md b/.agent/skills/ui-consistency/SKILL.md index e2f6f79..f1878ee 100644 --- a/.agent/skills/ui-consistency/SKILL.md +++ b/.agent/skills/ui-consistency/SKILL.md @@ -796,7 +796,42 @@ import { SearchableSelect } from "@/Components/ui/searchable-select"; ```tsx import { Calendar } from "lucide-react"; -import { Input } from "@/Components/ui/input"; + +## 11.7 金額與數字輸入規範 + +所有涉及金額(單價、成本、總價)的輸入框,應遵循以下規範以確保操作體驗一致: + +1. **HTML 屬性**: + * `type="number"` + * `min="0"` (除非業務邏輯允許負數) + * `step="any"` (設置為 `any` 可允許任意小數,且瀏覽器預設按上下鍵時會增減 **1** 並保留小數部分,例如 37.2 -> 38.2) + * **步進值 (Step)**: 金額與數量輸入框均應設定 `step="any"`,以支援小數點輸入(除非業務邏輯強制整數)。 + * `placeholder="0"` +2. **樣式類別**: + * 預設靠左對齊 (不需要 `text-right`),亦可依版面需求調整。 + +### 9.2 對齊方式 (Alignment) + +依據欄位所在的情境區分對齊方式: + +- **明細列表/表格 (Details/Table)**:金額與數量欄位一律 **靠右對齊 (text-right)**。 + - 包含:採購單明細、庫存盤點表、調撥單明細等 Table 內的輸入框。 +- **一般表單/新增欄位 (Form/Input)**:金額與數量欄位一律 **靠左對齊 (text-left)**。 + - 包含:商品資料設定、新增表單中的獨立欄位。亦可依版面需求調整。 +3. **行為邏輯**: + * 輸入時允許輸入小數點。 + * 鍵盤上下鍵調整時,瀏覽器會預設增減 1 (搭配 `step="any"`)。 + +```tsx + setPrice(parseFloat(e.target.value) || 0)} + placeholder="0" +/> +```
diff --git a/app/Modules/Inventory/Controllers/AdjustDocController.php b/app/Modules/Inventory/Controllers/AdjustDocController.php index 261a703..66300f7 100644 --- a/app/Modules/Inventory/Controllers/AdjustDocController.php +++ b/app/Modules/Inventory/Controllers/AdjustDocController.php @@ -181,6 +181,16 @@ class AdjustDocController extends Controller { $doc->load(['items.product.baseUnit', 'createdBy', 'postedBy', 'warehouse', 'countDoc']); + // Pre-fetch relevant Inventory information (mainly for expiry date) + $inventoryMap = \App\Modules\Inventory\Models\Inventory::withTrashed() + ->where('warehouse_id', $doc->warehouse_id) + ->whereIn('product_id', $doc->items->pluck('product_id')) + ->whereIn('batch_number', $doc->items->pluck('batch_number')) + ->get() + ->mapWithKeys(function ($inv) { + return [$inv->product_id . '-' . $inv->batch_number => $inv]; + }); + $docData = [ 'id' => (string) $doc->id, 'doc_no' => $doc->doc_no, @@ -193,13 +203,15 @@ class AdjustDocController extends Controller 'created_by' => $doc->createdBy?->name, 'count_doc_id' => $doc->count_doc_id ? (string)$doc->count_doc_id : null, 'count_doc_no' => $doc->countDoc?->doc_no, - 'items' => $doc->items->map(function ($item) { + 'items' => $doc->items->map(function ($item) use ($inventoryMap) { + $inv = $inventoryMap->get($item->product_id . '-' . $item->batch_number); return [ 'id' => (string) $item->id, 'product_id' => (string) $item->product_id, 'product_name' => $item->product->name, 'product_code' => $item->product->code, 'batch_number' => $item->batch_number, + 'expiry_date' => $inv && $inv->expiry_date ? $inv->expiry_date->format('Y-m-d') : null, 'unit' => $item->product->baseUnit?->name, 'qty_before' => (float) $item->qty_before, 'adjust_qty' => (float) $item->adjust_qty, diff --git a/app/Modules/Inventory/Controllers/CountDocController.php b/app/Modules/Inventory/Controllers/CountDocController.php index f40dec0..90d3853 100644 --- a/app/Modules/Inventory/Controllers/CountDocController.php +++ b/app/Modules/Inventory/Controllers/CountDocController.php @@ -94,6 +94,16 @@ class CountDocController extends Controller { $doc->load(['items.product.baseUnit', 'createdBy', 'completedBy', 'warehouse']); + // 預先抓取相關的 Inventory 資訊 (主要為了取得效期) + $inventoryMap = \App\Modules\Inventory\Models\Inventory::withTrashed() + ->where('warehouse_id', $doc->warehouse_id) + ->whereIn('product_id', $doc->items->pluck('product_id')) + ->whereIn('batch_number', $doc->items->pluck('batch_number')) + ->get() + ->mapWithKeys(function ($inv) { + return [$inv->product_id . '-' . $inv->batch_number => $inv]; + }); + $docData = [ 'id' => (string) $doc->id, 'doc_no' => $doc->doc_no, @@ -103,12 +113,16 @@ class CountDocController extends Controller 'remarks' => $doc->remarks, 'snapshot_date' => $doc->snapshot_date ? $doc->snapshot_date->format('Y-m-d H:i') : null, 'created_by' => $doc->createdBy?->name, - 'items' => $doc->items->map(function ($item) { + 'items' => $doc->items->map(function ($item) use ($inventoryMap) { + $key = $item->product_id . '-' . $item->batch_number; + $inv = $inventoryMap->get($key); + return [ 'id' => (string) $item->id, 'product_name' => $item->product->name, 'product_code' => $item->product->code, 'batch_number' => $item->batch_number, + 'expiry_date' => $inv && $inv->expiry_date ? $inv->expiry_date->format('Y-m-d') : null, // 新增效期 'unit' => $item->product->baseUnit?->name, 'system_qty' => (float) $item->system_qty, 'counted_qty' => is_null($item->counted_qty) ? '' : (float) $item->counted_qty, diff --git a/app/Modules/Inventory/Controllers/InventoryController.php b/app/Modules/Inventory/Controllers/InventoryController.php index 8f5cca7..68eada4 100644 --- a/app/Modules/Inventory/Controllers/InventoryController.php +++ b/app/Modules/Inventory/Controllers/InventoryController.php @@ -131,16 +131,18 @@ class InventoryController extends Controller { // ... (unchanged) ... $products = Product::with(['baseUnit', 'largeUnit']) - ->select('id', 'name', 'code', 'base_unit_id', 'large_unit_id', 'conversion_rate') + ->select('id', 'name', 'code', 'barcode', 'base_unit_id', 'large_unit_id', 'conversion_rate', 'cost_price') ->get() ->map(function ($product) { return [ 'id' => (string) $product->id, 'name' => $product->name, 'code' => $product->code, + 'barcode' => $product->barcode, 'baseUnit' => $product->baseUnit?->name ?? '個', 'largeUnit' => $product->largeUnit?->name, // 可能為 null 'conversionRate' => (float) $product->conversion_rate, + 'costPrice' => (float) $product->cost_price, ]; }); diff --git a/app/Modules/Inventory/Controllers/ProductController.php b/app/Modules/Inventory/Controllers/ProductController.php index f47a65e..b4cfb61 100644 --- a/app/Modules/Inventory/Controllers/ProductController.php +++ b/app/Modules/Inventory/Controllers/ProductController.php @@ -96,6 +96,10 @@ class ProductController extends Controller ] : null, 'conversionRate' => (float) $product->conversion_rate, 'location' => $product->location, + 'cost_price' => (float) $product->cost_price, + 'price' => (float) $product->price, + 'member_price' => (float) $product->member_price, + 'wholesale_price' => (float) $product->wholesale_price, ]; }); @@ -126,7 +130,12 @@ class ProductController extends Controller 'large_unit_id' => 'nullable|exists:units,id', 'conversion_rate' => 'required_with:large_unit_id|nullable|numeric|min:0.0001', 'purchase_unit_id' => 'nullable|exists:units,id', + 'purchase_unit_id' => 'nullable|exists:units,id', 'location' => 'nullable|string|max:255', + 'cost_price' => 'nullable|numeric|min:0', + 'price' => 'nullable|numeric|min:0', + 'member_price' => 'nullable|numeric|min:0', + 'wholesale_price' => 'nullable|numeric|min:0', ], [ 'code.required' => '商品代號為必填', 'code.max' => '商品代號最多 8 碼', @@ -142,6 +151,14 @@ class ProductController extends Controller 'conversion_rate.required_with' => '填寫大單位時,換算率為必填', 'conversion_rate.numeric' => '換算率必須為數字', 'conversion_rate.min' => '換算率最小為 0.0001', + 'cost_price.numeric' => '成本價必須為數字', + 'cost_price.min' => '成本價不能小於 0', + 'price.numeric' => '售價必須為數字', + 'price.min' => '售價不能小於 0', + 'member_price.numeric' => '會員價必須為數字', + 'member_price.min' => '會員價不能小於 0', + 'wholesale_price.numeric' => '批發價必須為數字', + 'wholesale_price.min' => '批發價不能小於 0', ]); $product = Product::create($validated); @@ -165,7 +182,12 @@ class ProductController extends Controller 'large_unit_id' => 'nullable|exists:units,id', 'conversion_rate' => 'required_with:large_unit_id|nullable|numeric|min:0.0001', 'purchase_unit_id' => 'nullable|exists:units,id', + 'purchase_unit_id' => 'nullable|exists:units,id', 'location' => 'nullable|string|max:255', + 'cost_price' => 'nullable|numeric|min:0', + 'price' => 'nullable|numeric|min:0', + 'member_price' => 'nullable|numeric|min:0', + 'wholesale_price' => 'nullable|numeric|min:0', ], [ 'code.required' => '商品代號為必填', 'code.max' => '商品代號最多 8 碼', @@ -181,6 +203,14 @@ class ProductController extends Controller 'conversion_rate.required_with' => '填寫大單位時,換算率為必填', 'conversion_rate.numeric' => '換算率必須為數字', 'conversion_rate.min' => '換算率最小為 0.0001', + 'cost_price.numeric' => '成本價必須為數字', + 'cost_price.min' => '成本價不能小於 0', + 'price.numeric' => '售價必須為數字', + 'price.min' => '售價不能小於 0', + 'member_price.numeric' => '會員價必須為數字', + 'member_price.min' => '會員價不能小於 0', + 'wholesale_price.numeric' => '批發價必須為數字', + 'wholesale_price.min' => '批發價不能小於 0', ]); $product->update($validated); diff --git a/app/Modules/Inventory/Controllers/TransferOrderController.php b/app/Modules/Inventory/Controllers/TransferOrderController.php index d6fc56d..75a4a6a 100644 --- a/app/Modules/Inventory/Controllers/TransferOrderController.php +++ b/app/Modules/Inventory/Controllers/TransferOrderController.php @@ -125,6 +125,7 @@ class TransferOrderController extends Controller 'product_name' => $item->product->name, 'product_code' => $item->product->code, 'batch_number' => $item->batch_number, + 'expiry_date' => $stock && $stock->expiry_date ? $stock->expiry_date->format('Y-m-d') : null, 'unit' => $item->product->baseUnit?->name, 'quantity' => (float) $item->quantity, 'max_quantity' => $item->snapshot_quantity ? (float) $item->snapshot_quantity : ($stock ? (float) $stock->quantity : 0.0), diff --git a/app/Modules/Inventory/Exports/ProductTemplateExport.php b/app/Modules/Inventory/Exports/ProductTemplateExport.php index bd10690..6d04e40 100644 --- a/app/Modules/Inventory/Exports/ProductTemplateExport.php +++ b/app/Modules/Inventory/Exports/ProductTemplateExport.php @@ -5,7 +5,7 @@ namespace App\Modules\Inventory\Exports; use Maatwebsite\Excel\Concerns\WithHeadings; use Maatwebsite\Excel\Concerns\WithColumnFormatting; -use Maatwebsite\Excel\Concerns\WithHeadings; +// use Maatwebsite\Excel\Concerns\WithHeadings; use PhpOffice\PhpSpreadsheet\Style\NumberFormat; class ProductTemplateExport implements WithHeadings, WithColumnFormatting @@ -22,6 +22,10 @@ class ProductTemplateExport implements WithHeadings, WithColumnFormatting '基本單位', '大單位', '換算率', + '成本價', + '售價', + '會員價', + '批發價', ]; } diff --git a/app/Modules/Inventory/Imports/ProductImport.php b/app/Modules/Inventory/Imports/ProductImport.php index 8f7e7dc..f881227 100644 --- a/app/Modules/Inventory/Imports/ProductImport.php +++ b/app/Modules/Inventory/Imports/ProductImport.php @@ -74,6 +74,10 @@ class ProductImport implements ToModel, WithHeadingRow, WithValidation, WithMapp 'large_unit_id' => $largeUnitId, 'conversion_rate' => $row['換算率'] ?? null, 'purchase_unit_id' => null, + 'cost_price' => $row['成本價'] ?? null, + 'price' => $row['售價'] ?? null, + 'member_price' => $row['會員價'] ?? null, + 'wholesale_price' => $row['批發價'] ?? null, ]); } @@ -100,6 +104,10 @@ class ProductImport implements ToModel, WithHeadingRow, WithValidation, WithMapp }], '換算率' => ['nullable', 'numeric', 'min:0.0001', 'required_with:大單位'], + '成本價' => ['nullable', 'numeric', 'min:0'], + '售價' => ['nullable', 'numeric', 'min:0'], + '會員價' => ['nullable', 'numeric', 'min:0'], + '批發價' => ['nullable', 'numeric', 'min:0'], ]; } } diff --git a/app/Modules/Inventory/Models/Product.php b/app/Modules/Inventory/Models/Product.php index ae42392..b8a018e 100644 --- a/app/Modules/Inventory/Models/Product.php +++ b/app/Modules/Inventory/Models/Product.php @@ -27,6 +27,10 @@ class Product extends Model 'conversion_rate', 'purchase_unit_id', 'location', + 'cost_price', + 'price', + 'member_price', + 'wholesale_price', ]; protected $casts = [ diff --git a/bootstrap/app.php b/bootstrap/app.php index a0c4ca7..819f2d6 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -4,10 +4,11 @@ use Illuminate\Foundation\Application; use Illuminate\Foundation\Configuration\Exceptions; use Illuminate\Foundation\Configuration\Middleware; use Illuminate\Http\Middleware\TrustProxies; +use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; use Symfony\Component\HttpKernel\Exception\HttpException; use Spatie\Permission\Exceptions\UnauthorizedException; use Inertia\Inertia; - +use Illuminate\Http\Request; return Application::configure(basePath: dirname(__DIR__)) ->withRouting( @@ -37,14 +38,24 @@ return Application::configure(basePath: dirname(__DIR__)) ->withExceptions(function (Exceptions $exceptions): void { // 處理 Spatie Permission 的 UnauthorizedException $exceptions->render(function (UnauthorizedException $e) { - return Inertia::render('Error/403')->toResponse(request())->setStatusCode(403); + return Inertia::render('Error/Index', ['status' => 403]) + ->toResponse(request()) + ->setStatusCode(403); }); - // 處理一般的 403 HttpException + // 處理 404 NotFoundHttpException + $exceptions->render(function (NotFoundHttpException $e) { + return Inertia::render('Error/Index', ['status' => 404]) + ->toResponse(request()) + ->setStatusCode(404); + }); + + // 處理其他一般的 HttpException (包含 403, 419, 429, 500, 503 等) $exceptions->render(function (HttpException $e) { - if ($e->getStatusCode() === 403) { - return Inertia::render('Error/403')->toResponse(request())->setStatusCode(403); - } + $status = $e->getStatusCode(); + return Inertia::render('Error/Index', ['status' => $status]) + ->toResponse(request()) + ->setStatusCode($status); }); })->create(); diff --git a/database/migrations/tenant/2026_02_05_103858_add_prices_to_products_table.php b/database/migrations/tenant/2026_02_05_103858_add_prices_to_products_table.php new file mode 100644 index 0000000..4596e78 --- /dev/null +++ b/database/migrations/tenant/2026_02_05_103858_add_prices_to_products_table.php @@ -0,0 +1,31 @@ +decimal('cost_price', 12, 2)->nullable()->after('location'); + $table->decimal('price', 12, 2)->nullable()->after('cost_price'); + $table->decimal('member_price', 12, 2)->nullable()->after('price'); + $table->decimal('wholesale_price', 12, 2)->nullable()->after('member_price'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('products', function (Blueprint $table) { + $table->dropColumn(['cost_price', 'price', 'member_price', 'wholesale_price']); + }); + } +}; diff --git a/resources/js/Components/Inventory/ScannerInput.tsx b/resources/js/Components/Inventory/ScannerInput.tsx new file mode 100644 index 0000000..dd0d51d --- /dev/null +++ b/resources/js/Components/Inventory/ScannerInput.tsx @@ -0,0 +1,161 @@ +import { useState, useRef, useEffect, KeyboardEvent } from 'react'; +import { Input } from '@/Components/ui/input'; +import { Label } from '@/Components/ui/label'; +import { Switch } from '@/Components/ui/switch'; +import { RefreshCcw, Scan, Zap } from 'lucide-react'; +import { cn } from '@/lib/utils'; + +interface ScannerInputProps { + onScan: (code: string, mode: 'continuous' | 'single') => void; + className?: string; + placeholder?: string; +} + +export default function ScannerInput({ onScan, className, placeholder = "點擊此處並掃描..." }: ScannerInputProps) { + const [code, setCode] = useState(''); + const [isContinuous, setIsContinuous] = useState(true); + const [lastAction, setLastAction] = useState<{ message: string; type: 'success' | 'info' | 'error'; time: number } | null>(null); + const [isFlashing, setIsFlashing] = useState(false); + const inputRef = useRef(null); + + // Focus input on mount + useEffect(() => { + if (inputRef.current) { + inputRef.current.focus(); + } + }, []); + + // Audio context for beep sound + const playBeep = (type: 'success' | 'error' = 'success') => { + try { + const AudioContext = window.AudioContext || (window as any).webkitAudioContext; + if (!AudioContext) return; + + const ctx = new AudioContext(); + const oscillator = ctx.createOscillator(); + const gainNode = ctx.createGain(); + + oscillator.connect(gainNode); + gainNode.connect(ctx.destination); + + if (type === 'success') { + oscillator.type = 'sine'; + oscillator.frequency.setValueAtTime(880, ctx.currentTime); // A5 + oscillator.frequency.exponentialRampToValueAtTime(440, ctx.currentTime + 0.1); + gainNode.gain.setValueAtTime(0.1, ctx.currentTime); + gainNode.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.1); + oscillator.start(); + oscillator.stop(ctx.currentTime + 0.1); + } else { + oscillator.type = 'sawtooth'; + oscillator.frequency.setValueAtTime(110, ctx.currentTime); // Low buzz + gainNode.gain.setValueAtTime(0.2, ctx.currentTime); + gainNode.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.2); + oscillator.start(); + oscillator.stop(ctx.currentTime + 0.2); + } + } catch (e) { + console.error('Audio playback failed', e); + } + }; + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Enter') { + e.preventDefault(); + if (code.trim()) { + handleScanSubmit(code.trim()); + } + } + }; + + const handleScanSubmit = (scannedCode: string) => { + // Trigger parent callback + onScan(scannedCode, isContinuous ? 'continuous' : 'single'); + + // UI Feedback + playBeep('success'); + setIsFlashing(true); + setTimeout(() => setIsFlashing(false), 300); + + // Show last action tip + setLastAction({ + message: `已掃描: ${scannedCode}`, + type: 'success', + time: Date.now() + }); + + // Clear input and focus + setCode(''); + }; + + // Public method to set last action message from parent (if needed for more context like product name) + // For now we just use internal state + + return ( +
+ + {/* Background flashy effect */} +
+ +
+ + {/* Left: Input Area */} +
+ + setCode(e.target.value)} + onKeyDown={handleKeyDown} + placeholder={placeholder} + className="pl-10 h-12 text-lg font-mono border-gray-300 focus:border-primary focus:ring-primary/20" + /> + {/* Continuous Mode Badge */} +
+ {isContinuous && ( +
+ + 連續模式 +
+ )} +
+
+ + {/* Right: Controls & Status */} +
+ + {/* Last Action Display */} +
+ {lastAction && (Date.now() - lastAction.time < 5000) ? ( +
+ {lastAction.message} + {isContinuous && 自動加總 (+1)} +
+ ) : ( + 等待掃描... + )} +
+ +
+ + {/* Toggle */} +
+ + +
+
+
+ +
+ + 提示:開啟連續模式時,掃描相同商品會自動將數量 +1;關閉則會視為新批號輸入。 +
+
+ ); +} diff --git a/resources/js/Components/Product/ProductDialog.tsx b/resources/js/Components/Product/ProductDialog.tsx index 550c6ca..1e55fe3 100644 --- a/resources/js/Components/Product/ProductDialog.tsx +++ b/resources/js/Components/Product/ProductDialog.tsx @@ -47,6 +47,10 @@ export default function ProductDialog({ conversion_rate: "", purchase_unit_id: "", location: "", + cost_price: "", + price: "", + member_price: "", + wholesale_price: "", }); useEffect(() => { @@ -65,6 +69,10 @@ export default function ProductDialog({ conversion_rate: product.conversionRate ? product.conversionRate.toString() : "", purchase_unit_id: product.purchaseUnitId?.toString() || "", location: product.location || "", + cost_price: product.cost_price?.toString() || "", + price: product.price?.toString() || "", + member_price: product.member_price?.toString() || "", + wholesale_price: product.wholesale_price?.toString() || "", }); } else { reset(); @@ -235,6 +243,72 @@ export default function ProductDialog({
+ {/* 價格設定區塊 */} +
+

價格設定

+
+
+ + setData("cost_price", e.target.value)} + placeholder="0" + className={errors.cost_price ? "border-red-500" : ""} + /> + {errors.cost_price &&

{errors.cost_price}

} +
+ +
+ + setData("price", e.target.value)} + placeholder="0" + className={errors.price ? "border-red-500" : ""} + /> + {errors.price &&

{errors.price}

} +
+ +
+ + setData("member_price", e.target.value)} + placeholder="0" + className={errors.member_price ? "border-red-500" : ""} + /> + {errors.member_price &&

{errors.member_price}

} +
+ +
+ + setData("wholesale_price", e.target.value)} + placeholder="0" + className={errors.wholesale_price ? "border-red-500" : ""} + /> + {errors.wholesale_price &&

{errors.wholesale_price}

} +
+
+
+ {/* 單位設定區塊 */}

單位設定

@@ -278,7 +352,7 @@ export default function ProductDialog({ setData("conversion_rate", e.target.value)} 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}` : ""} diff --git a/resources/js/Components/PurchaseOrder/PurchaseOrderItemsTable.tsx b/resources/js/Components/PurchaseOrder/PurchaseOrderItemsTable.tsx index 0e7a598..79c5402 100644 --- a/resources/js/Components/PurchaseOrder/PurchaseOrderItemsTable.tsx +++ b/resources/js/Components/PurchaseOrder/PurchaseOrderItemsTable.tsx @@ -110,13 +110,13 @@ export function PurchaseOrderItemsTable({ - onItemChange?.(index, "quantity", Math.floor(Number(e.target.value))) + onItemChange?.(index, "quantity", Number(e.target.value)) } disabled={isDisabled} - className="text-left w-24" + className="text-right w-24" /> )} @@ -189,13 +189,13 @@ export function PurchaseOrderItemsTable({ onItemChange?.(index, "subtotal", Number(e.target.value)) } disabled={isDisabled} - className={`text-left w-32 ${ + className={`text-right w-32 ${ // 如果有數量但沒有金額,顯示錯誤樣式 item.quantity > 0 && (!item.subtotal || item.subtotal <= 0) ? "border-red-400 bg-red-50 focus-visible:ring-red-500" diff --git a/resources/js/Components/SafetyStock/EditSafetyStockDialog.tsx b/resources/js/Components/SafetyStock/EditSafetyStockDialog.tsx index 312ee18..fccd479 100644 --- a/resources/js/Components/SafetyStock/EditSafetyStockDialog.tsx +++ b/resources/js/Components/SafetyStock/EditSafetyStockDialog.tsx @@ -78,6 +78,7 @@ export default function EditSafetyStockDialog({ id="safetyStock" type="number" min="1" + step="any" value={safetyStock} onChange={(e) => setSafetyStock(parseInt(e.target.value) || 0)} placeholder="請輸入安全庫存量" diff --git a/resources/js/Components/UtilityFee/UtilityFeeDialog.tsx b/resources/js/Components/UtilityFee/UtilityFeeDialog.tsx index 4a3c49c..1bb0710 100644 --- a/resources/js/Components/UtilityFee/UtilityFeeDialog.tsx +++ b/resources/js/Components/UtilityFee/UtilityFeeDialog.tsx @@ -172,7 +172,7 @@ export default function UtilityFeeDialog({ setData("amount", e.target.value)} placeholder="0.00" diff --git a/resources/js/Components/Vendor/AddSupplyProductDialog.tsx b/resources/js/Components/Vendor/AddSupplyProductDialog.tsx index 79cb979..d6fd911 100644 --- a/resources/js/Components/Vendor/AddSupplyProductDialog.tsx +++ b/resources/js/Components/Vendor/AddSupplyProductDialog.tsx @@ -159,6 +159,8 @@ export default function AddSupplyProductDialog({ setLastPrice(e.target.value)} diff --git a/resources/js/Components/Vendor/EditSupplyProductDialog.tsx b/resources/js/Components/Vendor/EditSupplyProductDialog.tsx index 5a065bb..1aa3fc6 100644 --- a/resources/js/Components/Vendor/EditSupplyProductDialog.tsx +++ b/resources/js/Components/Vendor/EditSupplyProductDialog.tsx @@ -86,6 +86,8 @@ export default function EditSupplyProductDialog({ setLastPrice(e.target.value)} diff --git a/resources/js/Components/Warehouse/Inventory/BatchAdjustmentModal.tsx b/resources/js/Components/Warehouse/Inventory/BatchAdjustmentModal.tsx index dd9500a..7541a17 100644 --- a/resources/js/Components/Warehouse/Inventory/BatchAdjustmentModal.tsx +++ b/resources/js/Components/Warehouse/Inventory/BatchAdjustmentModal.tsx @@ -123,7 +123,7 @@ export default function BatchAdjustmentModal({ setQuantity(e.target.value)} diff --git a/resources/js/Components/Warehouse/Inventory/InventoryAdjustmentDialog.tsx b/resources/js/Components/Warehouse/Inventory/InventoryAdjustmentDialog.tsx index 110f22d..a41a754 100644 --- a/resources/js/Components/Warehouse/Inventory/InventoryAdjustmentDialog.tsx +++ b/resources/js/Components/Warehouse/Inventory/InventoryAdjustmentDialog.tsx @@ -147,7 +147,7 @@ export default function InventoryAdjustmentDialog({ setData("quantity", Number(e.target.value))} placeholder="請輸入數量" diff --git a/resources/js/Components/Warehouse/Inventory/InventoryTable.tsx b/resources/js/Components/Warehouse/Inventory/InventoryTable.tsx index b5050fa..22b2ca5 100644 --- a/resources/js/Components/Warehouse/Inventory/InventoryTable.tsx +++ b/resources/js/Components/Warehouse/Inventory/InventoryTable.tsx @@ -4,7 +4,7 @@ */ import { useState } from "react"; -import { AlertTriangle, Edit, Trash2, Eye, ChevronDown, ChevronRight, CheckCircle, Package } from "lucide-react"; +import { AlertTriangle, Trash2, Eye, ChevronDown, ChevronRight, CheckCircle, Package } from "lucide-react"; import { Table, TableBody, @@ -28,13 +28,12 @@ import { import { GroupedInventory } from "@/types/warehouse"; import { formatDate } from "@/utils/format"; import { Can } from "@/Components/Permission/Can"; -import BatchAdjustmentModal from "./BatchAdjustmentModal"; + interface InventoryTableProps { inventories: GroupedInventory[]; onView: (id: string) => void; onDelete: (id: string) => void; - onAdjust: (batchId: string, data: { operation: string; quantity: number; reason: string }) => void; onViewProduct?: (productId: string) => void; } @@ -42,19 +41,12 @@ export default function InventoryTable({ inventories, onView, onDelete, - onAdjust, onViewProduct, }: InventoryTableProps) { // 每個商品的展開/折疊狀態 const [expandedProducts, setExpandedProducts] = useState>(new Set()); - // 調整彈窗狀態 - const [adjustmentTarget, setAdjustmentTarget] = useState<{ - id: string; - batchNumber: string; - currentQuantity: number; - productName: string; - } | null>(null); + if (inventories.length === 0) { return ( @@ -244,22 +236,7 @@ export default function InventoryTable({ > - - - + @@ -302,17 +279,7 @@ export default function InventoryTable({ ); })} - setAdjustmentTarget(null)} - batch={adjustmentTarget || undefined} - onConfirm={(data) => { - if (adjustmentTarget) { - onAdjust(adjustmentTarget.id, data); - setAdjustmentTarget(null); - } - }} - /> +
); diff --git a/resources/js/Components/Warehouse/SafetyStock/AddSafetyStockDialog.tsx b/resources/js/Components/Warehouse/SafetyStock/AddSafetyStockDialog.tsx index 51e16ea..2debcc5 100644 --- a/resources/js/Components/Warehouse/SafetyStock/AddSafetyStockDialog.tsx +++ b/resources/js/Components/Warehouse/SafetyStock/AddSafetyStockDialog.tsx @@ -231,7 +231,7 @@ export default function AddSafetyStockDialog({ updateQuantity(productId, parseFloat(e.target.value) || 0) diff --git a/resources/js/Components/Warehouse/SafetyStock/EditSafetyStockDialog.tsx b/resources/js/Components/Warehouse/SafetyStock/EditSafetyStockDialog.tsx index d983179..45e66e9 100644 --- a/resources/js/Components/Warehouse/SafetyStock/EditSafetyStockDialog.tsx +++ b/resources/js/Components/Warehouse/SafetyStock/EditSafetyStockDialog.tsx @@ -62,7 +62,7 @@ export default function EditSafetyStockDialog({ id="edit-safety" type="number" min="0" - step="1" + step="any" value={safetyStock} onChange={(e) => setSafetyStock(parseFloat(e.target.value) || 0)} className="button-outlined-primary" diff --git a/resources/js/Components/ui/searchable-select.tsx b/resources/js/Components/ui/searchable-select.tsx index d9bf554..1ac8362 100644 --- a/resources/js/Components/ui/searchable-select.tsx +++ b/resources/js/Components/ui/searchable-select.tsx @@ -92,7 +92,7 @@ export function SearchableSelect({ {shouldShowSearch && ( diff --git a/resources/js/Pages/Error/403.tsx b/resources/js/Pages/Error/403.tsx deleted file mode 100644 index 93ca739..0000000 --- a/resources/js/Pages/Error/403.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import { Link } from "@inertiajs/react"; -import { ShieldAlert, Home } from "lucide-react"; - -export default function Error403() { - return ( -
-
- {/* 圖示 */} -
-
- -
-
- - {/* 標題 */} -

- 無此權限 -

- - {/* 說明 */} -

- 您沒有存取此頁面的權限,請洽系統管理員。 -

- - {/* 返回按鈕 */} - - - 返回首頁 - -
-
- ); -} diff --git a/resources/js/Pages/Error/Index.tsx b/resources/js/Pages/Error/Index.tsx new file mode 100644 index 0000000..33ce122 --- /dev/null +++ b/resources/js/Pages/Error/Index.tsx @@ -0,0 +1,99 @@ +import { Head, Link } from "@inertiajs/react"; +import { ShieldAlert, FileQuestion, ServerCrash, HardHat, Home, ArrowLeft } from "lucide-react"; +import { Button } from "@/Components/ui/button"; + +interface Props { + status: number; + message?: string; +} + +export default function ErrorPage({ status, message }: Props) { + const errorDetails: Record = { + 403: { + title: "無此權限 (403)", + description: "抱歉,您沒有權限存取此頁面。如果您認為這是個錯誤,請聯繫系統管理員。", + icon: ShieldAlert, + color: "text-yellow-500 bg-yellow-100 border-yellow-200", + }, + 404: { + title: "頁面未找到 (404)", + description: "抱歉,我們找不到您要訪問的頁面。它可能已被移除、更改名稱或暫時不可用。", + icon: FileQuestion, + color: "text-blue-500 bg-blue-100 border-blue-200", + }, + 500: { + title: "伺服器錯誤 (500)", + description: "抱歉,伺服器發生了內部錯誤。我們的技術團隊已經收到通知,正在努力修復中。", + icon: ServerCrash, + color: "text-red-500 bg-red-100 border-red-200", + }, + 503: { + title: "服務維護中 (503)", + description: "抱歉,系統目前正在進行維護。請稍後再試。", + icon: HardHat, + color: "text-orange-500 bg-orange-100 border-orange-200", + }, + }; + + const defaultError = { + title: "發生錯誤", + description: message || "發生了未知的錯誤。", + icon: ShieldAlert, + color: "text-gray-500 bg-gray-100 border-gray-200", + }; + + const details = errorDetails[status] || defaultError; + const Icon = details.icon; + + return ( +
+ + +
{/* slide-in-bottom need to be defined in global css or just use simple animation */} + + {/* Icon Circle */} +
+
+
+ +
+
+ + {/* Text Content */} +

+ {details.title} +

+ +

+ {details.description} +

+ + {/* Actions */} +
+ + + + + +
+ +
+ Error Code: {status} | Star ERP System +
+
+
+ ); +} diff --git a/resources/js/Pages/Inventory/Adjust/Show.tsx b/resources/js/Pages/Inventory/Adjust/Show.tsx index 4a3999f..6ee0b45 100644 --- a/resources/js/Pages/Inventory/Adjust/Show.tsx +++ b/resources/js/Pages/Inventory/Adjust/Show.tsx @@ -48,8 +48,10 @@ interface AdjItem { qty_before: number | string; adjust_qty: number | string; notes: string; + expiry_date?: string | null; } + interface AdjDoc { id: string; doc_no: string; @@ -155,6 +157,7 @@ export default function Show({ doc }: { auth: any, doc: AdjDoc }) { qty_before: inv.quantity || 0, adjust_qty: 0, notes: '', + expiry_date: inv.expiry_date, }); addedCount++; } @@ -409,9 +412,10 @@ export default function Show({ doc }: { auth: any, doc: AdjDoc }) { onCheckedChange={() => toggleSelectAll()} /> - 商品代號 + 品名 批號 + 效期 現有庫存 @@ -447,9 +451,10 @@ export default function Show({ doc }: { auth: any, doc: AdjDoc }) { onCheckedChange={() => toggleSelect(key)} /> - {inv.product_code} + {inv.product_name} {inv.batch_number || '-'} + {inv.expiry_date || '-'} {inv.quantity} {inv.unit_name} ); @@ -532,7 +537,14 @@ export default function Show({ doc }: { auth: any, doc: AdjDoc }) { {item.product_code} - {item.batch_number || '-'} + +
{item.batch_number || '-'}
+ {item.expiry_date && ( +
+ 效期: {item.expiry_date} +
+ )} +
{item.unit} {item.qty_before} @@ -542,8 +554,8 @@ export default function Show({ doc }: { auth: any, doc: AdjDoc }) {
updateItem(index, 'adjust_qty', e.target.value)} /> @@ -569,9 +581,9 @@ export default function Show({ doc }: { auth: any, doc: AdjDoc }) { {!isReadOnly && !doc.count_doc_id && (
- {item.batch_number || '-'} + +
{item.batch_number || '-'}
+ {item.expiry_date && ( +
+ 效期: {item.expiry_date} +
+ )} +
{Number(item.system_qty)} {isReadOnly ? ( @@ -284,12 +291,12 @@ export default function Show({ doc }: any) { ) : ( updateItem(index, 'counted_qty', e.target.value)} onWheel={(e: any) => e.target.blur()} disabled={processing} - className="h-9 text-right font-medium focus:ring-primary-main" + className="h-9 font-medium focus:ring-primary-main text-right" placeholder="盤點..." /> )} diff --git a/resources/js/Pages/Inventory/GoodsReceipt/Create.tsx b/resources/js/Pages/Inventory/GoodsReceipt/Create.tsx index a9f8782..30e3637 100644 --- a/resources/js/Pages/Inventory/GoodsReceipt/Create.tsx +++ b/resources/js/Pages/Inventory/GoodsReceipt/Create.tsx @@ -38,13 +38,7 @@ import { STATUS_CONFIG } from '@/constants/purchase-order'; -interface BatchItem { - inventoryId: string; - batchNumber: string; - originCountry: string; - expiryDate: string | null; - quantity: number; -} + // 待進貨採購單 Item 介面 interface PendingPOItem { @@ -207,13 +201,12 @@ export default function GoodsReceiptCreate({ warehouses, pendingPurchaseOrders, }; // Batch management - const [batchesCache, setBatchesCache] = useState>({}); const [nextSequences, setNextSequences] = useState>({}); // Fetch batches and sequence for a product const fetchProductBatches = async (productId: number, country: string = 'TW', dateStr: string = '') => { if (!data.warehouse_id) return; - const cacheKey = `${productId}-${data.warehouse_id}`; + // const cacheKey = `${productId}-${data.warehouse_id}`; // Unused try { const today = new Date().toISOString().split('T')[0]; @@ -233,13 +226,7 @@ export default function GoodsReceiptCreate({ warehouses, pendingPurchaseOrders, ); if (response.data) { - // Update existing batches list - if (response.data.batches) { - setBatchesCache(prev => ({ - ...prev, - [cacheKey]: response.data.batches - })); - } + // Remove unused batch cache update // Update next sequence for new batch generation if (response.data.nextSequence !== undefined) { @@ -645,11 +632,11 @@ export default function GoodsReceiptCreate({ warehouses, pendingPurchaseOrders, updateItem(index, 'quantity_received', e.target.value)} - className={`w-full ${(errors as any)[errorKey] ? 'border-red-500' : ''}`} + className={`w-full text-right ${errors && (errors as any)[errorKey] ? 'border-red-500' : ''}`} /> {(errors as any)[errorKey] && (

{(errors as any)[errorKey]}

diff --git a/resources/js/Pages/Inventory/Transfer/Show.tsx b/resources/js/Pages/Inventory/Transfer/Show.tsx index d5a1f99..1fce89e 100644 --- a/resources/js/Pages/Inventory/Transfer/Show.tsx +++ b/resources/js/Pages/Inventory/Transfer/Show.tsx @@ -116,6 +116,7 @@ export default function Show({ order }: any) { product_name: inv.product_name, product_code: inv.product_code, batch_number: inv.batch_number, + expiry_date: inv.expiry_date, unit: inv.unit_name, quantity: 1, // Default 1 max_quantity: inv.quantity, // Max available @@ -371,9 +372,10 @@ export default function Show({ order }: any) { onCheckedChange={() => toggleSelectAll()} /> - 商品代號 + 品名 批號 + 效期 現有庫存 @@ -409,9 +411,10 @@ export default function Show({ order }: any) { onCheckedChange={() => toggleSelect(key)} />
- {inv.product_code} + {inv.product_name} {inv.batch_number || '-'} + {inv.expiry_date || '-'} {inv.quantity} {inv.unit_name} ); @@ -493,7 +496,14 @@ export default function Show({ order }: any) { {item.product_code}
- {item.batch_number || '-'} + +
{item.batch_number || '-'}
+ {item.expiry_date && ( +
+ 效期: {item.expiry_date} +
+ )} +
{item.max_quantity} {item.unit || item.unit_name} @@ -505,10 +515,10 @@ export default function Show({ order }: any) { handleUpdateItem(index, 'quantity', e.target.value)} - className="h-9 w-32 text-right font-medium focus:ring-primary-main" + className="h-9 w-32 font-medium focus:ring-primary-main text-right" /> )} @@ -528,7 +538,7 @@ export default function Show({ order }: any) { {!isReadOnly && ( - diff --git a/resources/js/Pages/Product/Index.tsx b/resources/js/Pages/Product/Index.tsx index 47f598b..cdb9a0f 100644 --- a/resources/js/Pages/Product/Index.tsx +++ b/resources/js/Pages/Product/Index.tsx @@ -40,6 +40,10 @@ export interface Product { location?: string; createdAt?: string; updatedAt?: string; + cost_price?: number; + price?: number; + member_price?: number; + wholesale_price?: number; } interface PageProps { diff --git a/resources/js/Pages/Production/Create.tsx b/resources/js/Pages/Production/Create.tsx index 580b1e2..9ce9e66 100644 --- a/resources/js/Pages/Production/Create.tsx +++ b/resources/js/Pages/Production/Create.tsx @@ -498,7 +498,7 @@ export default function ProductionCreate({ products, warehouses }: Props) { setData('output_quantity', e.target.value)} placeholder="例如: 50" @@ -691,11 +691,11 @@ export default function ProductionCreate({ products, warehouses }: Props) { updateBomItem(index, 'ui_input_quantity', e.target.value)} placeholder="0" - className="h-9" + className="h-9 text-right" disabled={!item.inventory_id} /> diff --git a/resources/js/Pages/Production/Edit.tsx b/resources/js/Pages/Production/Edit.tsx index db14376..9615f01 100644 --- a/resources/js/Pages/Production/Edit.tsx +++ b/resources/js/Pages/Production/Edit.tsx @@ -462,7 +462,7 @@ export default function ProductionEdit({ productionOrder, products, warehouses } setData('output_quantity', e.target.value)} placeholder="例如: 50" @@ -671,11 +671,11 @@ export default function ProductionEdit({ productionOrder, products, warehouses } updateBomItem(index, 'ui_input_quantity', e.target.value)} placeholder="0" - className="h-9" + className="h-9 text-right" disabled={!item.inventory_id} /> diff --git a/resources/js/Pages/Production/Recipe/Create.tsx b/resources/js/Pages/Production/Recipe/Create.tsx index 8f3d22c..5cee61d 100644 --- a/resources/js/Pages/Production/Recipe/Create.tsx +++ b/resources/js/Pages/Production/Recipe/Create.tsx @@ -190,6 +190,7 @@ export default function RecipeCreate({ products, units }: Props) {
setData('yield_quantity', e.target.value)} placeholder="1" @@ -264,10 +265,11 @@ export default function RecipeCreate({ products, units }: Props) { updateItem(index, 'quantity', e.target.value)} placeholder="數量" + className="text-right" /> diff --git a/resources/js/Pages/Production/Recipe/Edit.tsx b/resources/js/Pages/Production/Recipe/Edit.tsx index f1d6071..25d7970 100644 --- a/resources/js/Pages/Production/Recipe/Edit.tsx +++ b/resources/js/Pages/Production/Recipe/Edit.tsx @@ -215,6 +215,7 @@ export default function RecipeEdit({ recipe, products, units }: Props) {
setData('yield_quantity', e.target.value)} placeholder="1" @@ -289,10 +290,11 @@ export default function RecipeEdit({ recipe, products, units }: Props) { updateItem(index, 'quantity', e.target.value)} placeholder="數量" + className="text-right" /> diff --git a/resources/js/Pages/PurchaseOrder/Create.tsx b/resources/js/Pages/PurchaseOrder/Create.tsx index 45506bf..de5930a 100644 --- a/resources/js/Pages/PurchaseOrder/Create.tsx +++ b/resources/js/Pages/PurchaseOrder/Create.tsx @@ -342,7 +342,7 @@ export default function CreatePurchaseOrder({ onChange={(e) => setInvoiceAmount(e.target.value)} placeholder="0" min="0" - step="0.01" + step="any" className="block w-full" /> {invoiceAmount && totalAmount > 0 && parseFloat(invoiceAmount) !== totalAmount && ( @@ -419,6 +419,7 @@ export default function CreatePurchaseOrder({
{ setTaxAmount(e.target.value); diff --git a/resources/js/Pages/ShippingOrder/Create.tsx b/resources/js/Pages/ShippingOrder/Create.tsx index 185a650..411a819 100644 --- a/resources/js/Pages/ShippingOrder/Create.tsx +++ b/resources/js/Pages/ShippingOrder/Create.tsx @@ -260,8 +260,9 @@ export default function ShippingOrderCreate({ order, warehouses, products }: Pro type="number" value={item.quantity} onChange={e => updateItem(index, { quantity: parseFloat(e.target.value) || 0 })} - min={0.0001} - step={0.0001} + min={0} + step="any" + className="text-right" /> @@ -270,6 +271,8 @@ export default function ShippingOrderCreate({ order, warehouses, products }: Pro value={item.unit_price} onChange={e => updateItem(index, { unit_price: parseFloat(e.target.value) || 0 })} min={0} + step="any" + className="text-right" /> @@ -308,7 +311,8 @@ export default function ShippingOrderCreate({ order, warehouses, products }: Pro setTaxAmount(parseInt(e.target.value) || 0)} + onChange={e => setTaxAmount(parseFloat(e.target.value) || 0)} + step="any" className="h-8 text-right p-1" />
diff --git a/resources/js/Pages/Warehouse/AddInventory.tsx b/resources/js/Pages/Warehouse/AddInventory.tsx index 08316ca..8110059 100644 --- a/resources/js/Pages/Warehouse/AddInventory.tsx +++ b/resources/js/Pages/Warehouse/AddInventory.tsx @@ -23,14 +23,17 @@ import { Warehouse, InboundItem, InboundReason } from "@/types/warehouse"; import { getCurrentDateTime } from "@/utils/format"; import { toast } from "sonner"; import { getInventoryBreadcrumbs } from "@/utils/breadcrumb"; +import ScannerInput from "@/Components/Inventory/ScannerInput"; interface Product { id: string; name: string; code: string; + barcode?: string; baseUnit: string; largeUnit?: string; conversionRate?: number; + costPrice?: number; } interface Batch { @@ -113,9 +116,101 @@ export default function AddInventoryPage({ warehouse, products }: Props) { }); }, [items, inboundDate]); + // 處理掃碼輸入 + const handleScan = async (code: string, mode: 'continuous' | 'single') => { + const cleanCode = code.trim(); + // 1. 搜尋商品 (優先比對 Code, Barcode, ID) + let product = products.find(p => p.code === cleanCode || p.barcode === cleanCode || p.id === cleanCode); + + // 如果前端找不到,嘗試 API 搜尋 (Fallback) + if (!product) { + try { + // 這裡假設有 API 可以搜尋商品,若沒有則會失敗 + // 使用 Product/Index 的搜尋邏輯 (Inertia Props 比較難已 AJAX 取得) + // 替代方案:直接請求 /products?search=CLEAN_CODE&per_page=1 + // 加上 header 確認是 JSON 請求 + const response = await fetch(`/products?search=${encodeURIComponent(cleanCode)}&per_page=1`, { + headers: { + 'X-Requested-With': 'XMLHttpRequest', // 強制 AJAX 識別 + } + }); + + if (response.ok) { + const data = await response.json(); + // Inertia 回傳的是 component props 結構,或 partial props + // 根據 ProductController::index,回傳 props.products.data + if (data.props && data.props.products && data.props.products.data && data.props.products.data.length > 0) { + const foundProduct = data.props.products.data[0]; + // 轉換格式以符合 AddInventory 的 Product 介面 + product = { + id: foundProduct.id, + name: foundProduct.name, + code: foundProduct.code, + barcode: foundProduct.barcode, + baseUnit: foundProduct.baseUnit?.name || '個', + largeUnit: foundProduct.largeUnit?.name, + conversionRate: foundProduct.conversionRate, + costPrice: foundProduct.costPrice, + }; + } + } + } catch (err) { + console.error("API Search failed", err); + } + } + + if (!product) { + toast.error(`找不到商品: ${code}`); + return; + } + + // 2. 連續模式:尋找最近一筆相同商品並 +1 + if (mode === 'continuous') { + let foundIndex = -1; + // 從後往前搜尋,找到最近加入的那一筆 + for (let i = items.length - 1; i >= 0; i--) { + if (items[i].productId === product.id) { + foundIndex = i; + break; + } + } + + if (foundIndex !== -1) { + // 更新數量 + const newItems = [...items]; + const currentQty = newItems[foundIndex].quantity || 0; + newItems[foundIndex] = { + ...newItems[foundIndex], + quantity: currentQty + 1 + }; + setItems(newItems); + toast.success(`${product.name} 數量 +1 (總數: ${currentQty + 1})`); + return; + } + } + + // 3. 單筆模式 或 連續模式但尚未加入過:新增一筆 + const newItem: InboundItem = { + tempId: `temp-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, + productId: product.id, + productName: product.name, + quantity: 1, + unit: product.baseUnit, // 僅用於顯示當前選擇單位的名稱 + baseUnit: product.baseUnit, + largeUnit: product.largeUnit, + conversionRate: product.conversionRate, + selectedUnit: 'base', + batchMode: 'existing', // 預設選擇現有批號 (需要使用者確認/輸入) + originCountry: 'TW', + unit_cost: product.costPrice || 0, + }; + setItems(prev => [...prev, newItem]); + toast.success(`已加入 ${product.name}`); + }; + // 新增明細行 const handleAddItem = () => { - const defaultProduct = products.length > 0 ? products[0] : { id: "", name: "", code: "", baseUnit: "個" }; + const defaultProduct = products.length > 0 ? products[0] : { id: "", name: "", code: "", baseUnit: "個", costPrice: 0 }; const newItem: InboundItem = { tempId: `temp-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, productId: defaultProduct.id, @@ -128,6 +223,7 @@ export default function AddInventoryPage({ warehouse, products }: Props) { selectedUnit: 'base', batchMode: 'existing', // 預設選擇現有批號 originCountry: 'TW', + unit_cost: defaultProduct.costPrice || 0, }; setItems([...items, newItem]); }; @@ -162,6 +258,7 @@ export default function AddInventoryPage({ warehouse, products }: Props) { batchMode: 'existing', inventoryId: undefined, // 清除已選擇的批號 expiryDate: undefined, + unit_cost: product.costPrice || 0, }); } }; @@ -224,7 +321,8 @@ export default function AddInventoryPage({ warehouse, products }: Props) { batchMode: item.batchMode, inventoryId: item.inventoryId, originCountry: item.originCountry, - expiryDate: item.expiryDate + expiryDate: item.expiryDate, + unit_cost: item.unit_cost }; }) }, { @@ -384,6 +482,12 @@ export default function AddInventoryPage({ warehouse, products }: Props) {
+ {/* 掃碼輸入區 */} + + {errors.items && (

{errors.items}

)} @@ -399,12 +503,13 @@ export default function AddInventoryPage({ warehouse, products }: Props) { 批號 * + + 單價 + 數量 * 單位 - 轉換數量 - 效期 @@ -479,46 +584,90 @@ export default function AddInventoryPage({ warehouse, products }: Props) { )} {item.batchMode === 'new' && ( -
-
- { - const val = e.target.value.toUpperCase().slice(0, 2); - handleUpdateItem(item.tempId, { originCountry: val }); - }} - maxLength={2} - placeholder="產地" - className="h-8 text-xs text-center border-gray-300" - /> + <> +
+
+ { + const val = e.target.value.toUpperCase().slice(0, 2); + handleUpdateItem(item.tempId, { originCountry: val }); + }} + maxLength={2} + placeholder="產地" + className="h-8 text-xs text-center border-gray-300" + /> +
+
+ {getBatchPreview(item.productId, product?.code, item.originCountry || 'TW', inboundDate)} +
-
- {getBatchPreview(item.productId, product?.code, item.originCountry || 'TW', inboundDate)} + {/* 新增效期輸入 (在新增批號模式下) */} +
+ 效期: +
+ + + handleUpdateItem(item.tempId, { + expiryDate: e.target.value, + }) + } + className="h-8 pl-8 text-xs border-gray-300 w-full" + /> +
-
+ )} {item.batchMode === 'existing' && item.inventoryId && ( -
- 效期: {item.expiryDate || '未設定'} +
+
+ 效期: {item.expiryDate || '未設定'} +
)}
+ {/* 單價 */} + + + handleUpdateItem(item.tempId, { + unit_cost: parseFloat(e.target.value) || 0, + }) + } + className="border-gray-300 bg-gray-50 text-right" + placeholder="0" + /> + + {/* 數量 */} handleUpdateItem(item.tempId, { quantity: parseFloat(e.target.value) || 0, }) } - className="border-gray-300" + className="border-gray-300 text-right" /> + {item.selectedUnit === 'large' && item.conversionRate && ( +
+ 轉換: {convertedQuantity} {item.baseUnit || "個"} +
+ )} {errors[`item-${index}-quantity`] && (

{errors[`item-${index}-quantity`]} @@ -544,48 +693,20 @@ export default function AddInventoryPage({ warehouse, products }: Props) { className="border-gray-300" /> ) : ( - +

+ {item.baseUnit || "個"} +
)}
- {/* 轉換數量 */} - -
- {convertedQuantity} - {item.baseUnit || "個"} -
-
- - {/* 效期 */} - -
- - - handleUpdateItem(item.tempId, { - expiryDate: e.target.value, - }) - } - disabled={item.batchMode === 'existing'} - className={`border-gray-300 pl-9 ${item.batchMode === 'existing' ? 'bg-gray-50' : ''}`} - /> -
-
- {/* 刪除按鈕 */} @@ -605,6 +726,6 @@ export default function AddInventoryPage({ warehouse, products }: Props) {
- + ); } diff --git a/resources/js/Pages/Warehouse/EditInventory.tsx b/resources/js/Pages/Warehouse/EditInventory.tsx index 6dd208f..69010b0 100644 --- a/resources/js/Pages/Warehouse/EditInventory.tsx +++ b/resources/js/Pages/Warehouse/EditInventory.tsx @@ -169,7 +169,7 @@ export default function EditInventory({ warehouse, inventory, transactions = [] id="quantity" type="number" min="0" - step="0.01" + step="any" value={data.quantity} onChange={(e) => setData("quantity", parseFloat(e.target.value) || 0) diff --git a/resources/js/Pages/Warehouse/Inventory.tsx b/resources/js/Pages/Warehouse/Inventory.tsx index a66723f..b051a64 100644 --- a/resources/js/Pages/Warehouse/Inventory.tsx +++ b/resources/js/Pages/Warehouse/Inventory.tsx @@ -101,16 +101,7 @@ export default function WarehouseInventoryPage({ }); }; - const handleAdjust = (batchId: string, data: { operation: string; quantity: number; reason: string }) => { - router.put(route("warehouses.inventory.update", { warehouse: warehouse.id, inventoryId: batchId }), data, { - onSuccess: () => { - toast.success("庫存已更新"); - }, - onError: () => { - toast.error("庫存更新失敗"); - } - }); - }; + return ( @@ -195,7 +186,6 @@ export default function WarehouseInventoryPage({ inventories={filteredInventories} onView={handleView} onDelete={confirmDelete} - onAdjust={handleAdjust} onViewProduct={handleViewProduct} />