feat: 標準化全系統數值輸入欄位與擴充商品價格功能
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:
161
resources/js/Components/Inventory/ScannerInput.tsx
Normal file
161
resources/js/Components/Inventory/ScannerInput.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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}` : ""}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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="請輸入安全庫存量"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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="請輸入數量"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
Reference in New Issue
Block a user