feat: 標準化全系統數值輸入欄位與擴充商品價格功能
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Has been skipped
Koori-ERP-Deploy-System / deploy-production (push) Successful in 1m0s

1. UI 標準化:
   - 針對全系統數值輸入欄位統一加上 step='any' 以支援小數點。
   - 表格形式 (Table) 的數值輸入欄位統一加上 text-right 靠右對齊。
   - 修正 Components 與 Pages 中所有涉及金額與數量的輸入框。

2. 功能擴充與修正:
   - 擴充 Product 模型與相關 Dialog 以支援多種價格設定。
   - 修正 Inventory/GoodsReceipt/Create.tsx 未使用的變數錯誤。
   - 優化庫存相關頁面的 UI 一致性。

3. 其他:
   - 更新相關的 Type 定義與 Controller 邏輯。
This commit is contained in:
2026-02-05 11:45:08 +08:00
parent 04f3891275
commit 3ce96537b3
40 changed files with 774 additions and 212 deletions

View File

@@ -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<HTMLInputElement>(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<HTMLInputElement>) => {
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 (
<div className={cn("bg-white p-4 rounded-xl border-2 shadow-sm transition-all relative overflow-hidden", isFlashing ? "border-green-500 bg-green-50" : "border-primary/20", className)}>
{/* Background flashy effect */}
<div className={cn("absolute inset-0 bg-green-400/20 transition-opacity duration-300 pointer-events-none", isFlashing ? "opacity-100" : "opacity-0")} />
<div className="flex flex-col md:flex-row gap-4 items-center justify-between relative z-10">
{/* Left: Input Area */}
<div className="flex-1 w-full relative">
<Scan className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 h-5 w-5" />
<Input
ref={inputRef}
value={code}
onChange={(e) => 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 */}
<div className="absolute right-2 top-1/2 -translate-y-1/2 flex items-center gap-2">
{isContinuous && (
<div className="bg-primary/10 text-primary text-xs font-bold px-2 py-1 rounded-md flex items-center gap-1">
<Zap className="h-3 w-3 fill-primary" />
</div>
)}
</div>
</div>
{/* Right: Controls & Status */}
<div className="flex items-center gap-6 w-full md:w-auto justify-between md:justify-end">
{/* Last Action Display */}
<div className="flex-1 md:flex-none text-right min-h-[40px] flex flex-col justify-center">
{lastAction && (Date.now() - lastAction.time < 5000) ? (
<div className="animate-in fade-in slide-in-from-right-4 duration-300">
<span className="text-sm font-bold text-gray-800 block">{lastAction.message}</span>
{isContinuous && <span className="text-xs text-green-600 font-bold block"> (+1)</span>}
</div>
) : (
<span className="text-gray-400 text-sm">...</span>
)}
</div>
<div className="h-8 w-px bg-gray-200 hidden md:block"></div>
{/* Toggle */}
<div className="flex items-center gap-2">
<Label htmlFor="continuous-mode" className={cn("text-sm font-bold cursor-pointer select-none", isContinuous ? "text-primary" : "text-gray-500")}>
</Label>
<Switch
id="continuous-mode"
checked={isContinuous}
onCheckedChange={setIsContinuous}
/>
</div>
</div>
</div>
<div className="mt-2 flex items-center gap-2 text-xs text-gray-400 px-1">
<RefreshCcw className="h-3 w-3" />
<span> +1</span>
</div>
</div>
);
}

View File

@@ -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({
</div>
</div>
{/* 價格設定區塊 */}
<div className="space-y-4">
<h3 className="text-lg font-medium border-b pb-2"></h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="cost_price"></Label>
<Input
id="cost_price"
type="number"
min="0"
step="any"
value={data.cost_price}
onChange={(e) => setData("cost_price", e.target.value)}
placeholder="0"
className={errors.cost_price ? "border-red-500" : ""}
/>
{errors.cost_price && <p className="text-sm text-red-500">{errors.cost_price}</p>}
</div>
<div className="space-y-2">
<Label htmlFor="price"></Label>
<Input
id="price"
type="number"
min="0"
step="any"
value={data.price}
onChange={(e) => setData("price", e.target.value)}
placeholder="0"
className={errors.price ? "border-red-500" : ""}
/>
{errors.price && <p className="text-sm text-red-500">{errors.price}</p>}
</div>
<div className="space-y-2">
<Label htmlFor="member_price"></Label>
<Input
id="member_price"
type="number"
min="0"
step="any"
value={data.member_price}
onChange={(e) => setData("member_price", e.target.value)}
placeholder="0"
className={errors.member_price ? "border-red-500" : ""}
/>
{errors.member_price && <p className="text-sm text-red-500">{errors.member_price}</p>}
</div>
<div className="space-y-2">
<Label htmlFor="wholesale_price"></Label>
<Input
id="wholesale_price"
type="number"
min="0"
step="any"
value={data.wholesale_price}
onChange={(e) => setData("wholesale_price", e.target.value)}
placeholder="0"
className={errors.wholesale_price ? "border-red-500" : ""}
/>
{errors.wholesale_price && <p className="text-sm text-red-500">{errors.wholesale_price}</p>}
</div>
</div>
</div>
{/* 單位設定區塊 */}
<div className="space-y-4">
<h3 className="text-lg font-medium border-b pb-2"></h3>
@@ -278,7 +352,7 @@ export default function ProductDialog({
<Input
id="conversion_rate"
type="number"
step="0.0001"
step="any"
value={data.conversion_rate}
onChange={(e) => 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}` : ""}

View File

@@ -110,13 +110,13 @@ export function PurchaseOrderItemsTable({
<Input
type="number"
min="0"
step="1"
step="any"
value={item.quantity === 0 ? "" : Math.floor(item.quantity)}
onChange={(e) =>
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"
/>
)}
</TableCell>
@@ -189,13 +189,13 @@ export function PurchaseOrderItemsTable({
<Input
type="number"
min="0"
step="1"
step="any"
value={item.subtotal || ""}
onChange={(e) =>
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"

View File

@@ -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="請輸入安全庫存量"

View File

@@ -172,7 +172,7 @@ export default function UtilityFeeDialog({
<Input
id="amount"
type="number"
step="0.01"
step="any"
value={data.amount}
onChange={(e) => setData("amount", e.target.value)}
placeholder="0.00"

View File

@@ -159,6 +159,8 @@ export default function AddSupplyProductDialog({
</Label>
<Input
type="number"
min="0"
step="any"
placeholder="輸入價格"
value={lastPrice}
onChange={(e) => setLastPrice(e.target.value)}

View File

@@ -86,6 +86,8 @@ export default function EditSupplyProductDialog({
<Label className="text-muted-foreground text-xs"> / {product.baseUnit || "單位"}</Label>
<Input
type="number"
min="0"
step="any"
placeholder="輸入價格"
value={lastPrice}
onChange={(e) => setLastPrice(e.target.value)}

View File

@@ -123,7 +123,7 @@ export default function BatchAdjustmentModal({
<Input
id="adj-qty"
type="number"
step="0.01"
step="any"
min="0"
value={quantity}
onChange={(e) => setQuantity(e.target.value)}

View File

@@ -147,7 +147,7 @@ export default function InventoryAdjustmentDialog({
<Input
id="quantity"
type="number"
step="0.01"
step="any"
value={data.quantity === 0 ? "" : data.quantity}
onChange={e => setData("quantity", Number(e.target.value))}
placeholder="請輸入數量"

View File

@@ -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<Set<string>>(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({
>
<Eye className="h-4 w-4" />
</Button>
<Can permission="inventory.adjust">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setAdjustmentTarget({
id: batch.id,
batchNumber: batch.batchNumber,
currentQuantity: batch.quantity,
productName: group.productName
})}
className="button-outlined-primary"
>
<Edit className="h-4 w-4" />
</Button>
</Can>
<Can permission="inventory.delete">
<Tooltip>
<TooltipTrigger asChild>
@@ -302,17 +279,7 @@ export default function InventoryTable({
);
})}
<BatchAdjustmentModal
isOpen={!!adjustmentTarget}
onClose={() => setAdjustmentTarget(null)}
batch={adjustmentTarget || undefined}
onConfirm={(data) => {
if (adjustmentTarget) {
onAdjust(adjustmentTarget.id, data);
setAdjustmentTarget(null);
}
}}
/>
</div>
</TooltipProvider>
);

View File

@@ -231,7 +231,7 @@ export default function AddSafetyStockDialog({
<Input
type="number"
min="0"
step="1"
step="any"
value={quantity || ""}
onChange={(e) =>
updateQuantity(productId, parseFloat(e.target.value) || 0)

View File

@@ -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"

View File

@@ -92,7 +92,7 @@ export function SearchableSelect({
<PopoverContent
className="p-0 z-[9999]"
align="start"
style={{ width: "var(--radix-popover-trigger-width)" }}
style={{ width: "var(--radix-popover-trigger-width)", minWidth: "12rem" }}
>
<Command>
{shouldShowSearch && (