1. UI 標準化: - 針對全系統數值輸入欄位統一加上 step='any' 以支援小數點。 - 表格形式 (Table) 的數值輸入欄位統一加上 text-right 靠右對齊。 - 修正 Components 與 Pages 中所有涉及金額與數量的輸入框。 2. 功能擴充與修正: - 擴充 Product 模型與相關 Dialog 以支援多種價格設定。 - 修正 Inventory/GoodsReceipt/Create.tsx 未使用的變數錯誤。 - 優化庫存相關頁面的 UI 一致性。 3. 其他: - 更新相關的 Type 定義與 Controller 邏輯。
162 lines
7.0 KiB
TypeScript
162 lines
7.0 KiB
TypeScript
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>
|
||
);
|
||
}
|