diff --git a/frontend/components/inventory/QuantityAdjustmentModal.tsx b/frontend/components/inventory/QuantityAdjustmentModal.tsx new file mode 100644 index 00000000..8134db06 --- /dev/null +++ b/frontend/components/inventory/QuantityAdjustmentModal.tsx @@ -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(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 ( +
+
+ {/* Header */} +
+

Adjust quantity

+ +
+ + {/* Content */} +
+ {/* Item Details */} +
+

+ {item.name} +

+
+ {item.part_number && ( +
Part Number: {item.part_number}
+ )} + {item.barcode && ( +
Barcode: {item.barcode}
+ )} + {item.category && ( +
Category: {item.category}
+ )} +
+
+ + {/* Success Message */} + {successMessage && ( +
+ {successMessage} +
+ )} + + {/* Quantity Adjustment */} +
+ + +
+ + {/* Action Buttons */} +
+ +
+
+
+
+ ); +} diff --git a/frontend/components/inventory/SearchModal.tsx b/frontend/components/inventory/SearchModal.tsx new file mode 100644 index 00000000..13a26dfe --- /dev/null +++ b/frontend/components/inventory/SearchModal.tsx @@ -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([]); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const inputRef = useRef(null); + const debounceTimerRef = useRef(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) => { + 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 ( +
+
+ {/* Header */} +
+

Search inventory

+ +
+ + {/* Search Input */} +
+
+ + +
+
+ + {/* Results */} +
+ {error && ( +
+ {error} +
+ )} + + {isLoading && ( +
+
+
+
+
+ )} + + {!isLoading && !error && results.length === 0 && query.length > 0 && ( +
+ No items found matching "{query}" +
+ )} + + {!isLoading && !error && results.length === 0 && query.length === 0 && ( +
+ Start typing to search +
+ )} + + {!isLoading && results.length > 0 && ( +
+ {results.map((item) => ( + + ))} +
+ )} +
+
+
+ ); +}