feat(5-plan-02-t2,t5): create SearchModal and QuantityAdjustmentModal components for inventory search
This commit is contained in:
133
frontend/components/inventory/QuantityAdjustmentModal.tsx
Normal file
133
frontend/components/inventory/QuantityAdjustmentModal.tsx
Normal file
@@ -0,0 +1,133 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { X } from 'lucide-react';
|
||||
import QuantityDisplay from './QuantityDisplay';
|
||||
import axios from 'axios';
|
||||
|
||||
interface Item {
|
||||
id: number;
|
||||
name: string;
|
||||
quantity: number;
|
||||
part_number?: string;
|
||||
barcode?: string;
|
||||
category?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
interface QuantityAdjustmentModalProps {
|
||||
item: Item | null;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function QuantityAdjustmentModal({
|
||||
item,
|
||||
isOpen,
|
||||
onClose,
|
||||
}: QuantityAdjustmentModalProps) {
|
||||
const [successMessage, setSuccessMessage] = useState<string | null>(null);
|
||||
const [isClosing, setIsClosing] = useState(false);
|
||||
|
||||
// Handle closing
|
||||
const handleClose = () => {
|
||||
setIsClosing(true);
|
||||
setTimeout(() => {
|
||||
onClose();
|
||||
setIsClosing(false);
|
||||
setSuccessMessage(null);
|
||||
}, 300);
|
||||
};
|
||||
|
||||
// Handle quantity change via QuantityDisplay
|
||||
const handleQuantityChange = async (newQuantity: number) => {
|
||||
if (!item) return;
|
||||
|
||||
try {
|
||||
const backendUrl = process.env.NEXT_PUBLIC_BACKEND_URL || 'http://localhost:8906';
|
||||
await axios.patch(`${backendUrl}/items/${item.id}`, {
|
||||
quantity: newQuantity,
|
||||
});
|
||||
|
||||
setSuccessMessage(`Quantity updated to ${newQuantity}`);
|
||||
setTimeout(() => {
|
||||
setSuccessMessage(null);
|
||||
handleClose();
|
||||
}, 1500);
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : 'Failed to update quantity';
|
||||
console.error('Quantity update error:', errorMsg);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen || !item) return null;
|
||||
|
||||
return (
|
||||
<div className={`fixed inset-0 z-50 bg-black/50 flex items-center justify-center transition-opacity ${isClosing ? 'opacity-0' : 'opacity-100'}`}>
|
||||
<div className={`bg-slate-950 rounded-lg shadow-lg w-full max-w-md mx-4 transition-transform ${isClosing ? 'scale-95' : 'scale-100'}`}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-slate-800">
|
||||
<h2 className="text-lg font-normal">Adjust quantity</h2>
|
||||
<button
|
||||
onClick={handleClose}
|
||||
aria-label="Close quantity adjustment modal"
|
||||
className="p-2 hover:bg-slate-800 rounded-lg transition-colors"
|
||||
>
|
||||
<X size={20} className="text-slate-400" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Item Details */}
|
||||
<div>
|
||||
<h3 className="text-2xl font-normal text-slate-100 mb-2">
|
||||
{item.name}
|
||||
</h3>
|
||||
<div className="text-sm text-slate-500 space-y-1">
|
||||
{item.part_number && (
|
||||
<div>Part Number: {item.part_number}</div>
|
||||
)}
|
||||
{item.barcode && (
|
||||
<div>Barcode: {item.barcode}</div>
|
||||
)}
|
||||
{item.category && (
|
||||
<div>Category: {item.category}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Success Message */}
|
||||
{successMessage && (
|
||||
<div className="p-3 bg-green-500/10 border border-green-500/20 rounded-lg text-green-500 text-sm">
|
||||
{successMessage}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Quantity Adjustment */}
|
||||
<div>
|
||||
<label className="text-sm font-medium text-slate-400 mb-3 block">
|
||||
Current quantity
|
||||
</label>
|
||||
<QuantityDisplay
|
||||
itemId={String(item.id)}
|
||||
currentQuantity={item.quantity}
|
||||
onQuantityChange={handleQuantityChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex gap-2 pt-2">
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="flex-1 px-4 py-2 bg-slate-800 hover:bg-slate-700 text-slate-100 rounded-lg font-medium transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
216
frontend/components/inventory/SearchModal.tsx
Normal file
216
frontend/components/inventory/SearchModal.tsx
Normal file
@@ -0,0 +1,216 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { X, Search } from 'lucide-react';
|
||||
|
||||
interface Item {
|
||||
id: number;
|
||||
name: string;
|
||||
part_number?: string;
|
||||
barcode?: string;
|
||||
quantity: number;
|
||||
}
|
||||
|
||||
interface SearchModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSelectItem: (item: Item) => void;
|
||||
}
|
||||
|
||||
export default function SearchModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
onSelectItem,
|
||||
}: SearchModalProps) {
|
||||
const [query, setQuery] = useState('');
|
||||
const [results, setResults] = useState<Item[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const debounceTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
// Auto-focus input when modal opens
|
||||
useEffect(() => {
|
||||
if (isOpen && inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
// Debounced search
|
||||
const performSearch = useCallback(async (searchQuery: string) => {
|
||||
if (searchQuery.length < 1) {
|
||||
setResults([]);
|
||||
setError(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const backendUrl = process.env.NEXT_PUBLIC_BACKEND_URL || 'http://localhost:8906';
|
||||
const response = await fetch(`${backendUrl}/items/search?q=${encodeURIComponent(searchQuery)}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Search failed');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
setResults(data || []);
|
||||
} catch (err) {
|
||||
const errorMsg = err instanceof Error ? err.message : 'Search failed';
|
||||
setError(errorMsg);
|
||||
setResults([]);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Handle input change with debouncing (300ms)
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newQuery = e.target.value;
|
||||
setQuery(newQuery);
|
||||
|
||||
// Clear previous debounce timer
|
||||
if (debounceTimerRef.current) {
|
||||
clearTimeout(debounceTimerRef.current);
|
||||
}
|
||||
|
||||
// Set new debounce timer
|
||||
debounceTimerRef.current = setTimeout(() => {
|
||||
performSearch(newQuery);
|
||||
}, 300);
|
||||
};
|
||||
|
||||
// Handle Escape key
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
// Handle item selection
|
||||
const handleSelectItem = (item: Item) => {
|
||||
onSelectItem(item);
|
||||
setQuery('');
|
||||
setResults([]);
|
||||
onClose();
|
||||
};
|
||||
|
||||
// Cleanup debounce on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (debounceTimerRef.current) {
|
||||
clearTimeout(debounceTimerRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 bg-black/50 flex items-start justify-center pt-20">
|
||||
<div className="bg-slate-950 rounded-lg shadow-lg w-full max-w-2xl mx-4 max-h-[80vh] flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-slate-800">
|
||||
<h2 className="text-xl font-normal">Search inventory</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
aria-label="Close search modal"
|
||||
className="p-2 hover:bg-slate-800 rounded-lg transition-colors"
|
||||
>
|
||||
<X size={20} className="text-slate-400" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Search Input */}
|
||||
<div className="p-4 border-b border-slate-800">
|
||||
<div className="relative">
|
||||
<Search size={18} className="absolute left-3 top-3 text-slate-500 pointer-events-none" />
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
placeholder="Search by name, PN, barcode..."
|
||||
value={query}
|
||||
onChange={handleInputChange}
|
||||
aria-label="Search items"
|
||||
className="w-full bg-slate-900 border border-slate-800 rounded-lg pl-10 pr-4 py-2 text-slate-100 placeholder-slate-600 focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary/50"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Results */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{error && (
|
||||
<div className="p-4 text-rose-500 bg-rose-500/10 border border-rose-500/20 m-4 rounded-lg">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading && (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="text-slate-400">
|
||||
<div className="inline-block animate-spin rounded-full h-6 w-6 border border-slate-600 border-t-primary"></div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && !error && results.length === 0 && query.length > 0 && (
|
||||
<div className="p-4 text-slate-500 text-center">
|
||||
No items found matching "{query}"
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && !error && results.length === 0 && query.length === 0 && (
|
||||
<div className="p-4 text-slate-500 text-center">
|
||||
Start typing to search
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && results.length > 0 && (
|
||||
<div className="divide-y divide-slate-800">
|
||||
{results.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => handleSelectItem(item)}
|
||||
className="w-full p-4 text-left hover:bg-slate-900 transition-colors focus:outline-none focus:bg-slate-900 focus:ring-2 focus:ring-primary/50"
|
||||
>
|
||||
<div className="flex justify-between items-start gap-2 mb-1">
|
||||
<h3 className="font-medium text-slate-100 flex-1">
|
||||
{item.name}
|
||||
</h3>
|
||||
<span className="text-primary font-medium text-sm whitespace-nowrap">
|
||||
Qty: {item.quantity}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-slate-500 space-y-1">
|
||||
{item.part_number && (
|
||||
<div>PN: {item.part_number}</div>
|
||||
)}
|
||||
{item.barcode && (
|
||||
<div>Barcode: {item.barcode}</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user