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({
+ {/* 價格設定區塊 */}
+