diff --git a/backend/routers/items.py b/backend/routers/items.py index 0b97fba5..214d4c8b 100644 --- a/backend/routers/items.py +++ b/backend/routers/items.py @@ -22,6 +22,94 @@ router = APIRouter( tags=["Items"] ) +@router.get("/search") +def search_items( + q: str, + db: Session = Depends(get_db), + current_user: auth.TokenData = Depends(auth.get_current_user) +): + """[PHASE-5-T1] Search items across all text fields (name, part_number, barcode, description, category, notes). + + Real-time search with relevance scoring. Returns max 50 results. + - q: query string (min 1 char, max 100 chars) + - Returns: List of matching items ordered by relevance + """ + # Validate query + if not q or len(q) < 1 or len(q) > 100: + return [] + + query_lower = q.lower() + + # Get all items (we'll do client-side scoring for flexible matching) + all_items = db.query(models.Item).all() + + # Score each item based on matches across all text fields + scored_items = [] + for item in all_items: + score = 0 + + # Name match (highest priority) + if item.name: + item_name_lower = item.name.lower() + if query_lower == item_name_lower: + score += 500 # Exact match + elif item_name_lower.startswith(query_lower): + score += 250 # Prefix match + elif query_lower in item_name_lower: + score += 100 # Substring match + + # Part number match + if item.part_number: + pn_lower = item.part_number.lower() + if query_lower == pn_lower: + score += 200 + elif pn_lower.startswith(query_lower): + score += 150 + elif query_lower in pn_lower: + score += 50 + + # Barcode match + if item.barcode: + barcode_lower = item.barcode.lower() + if query_lower == barcode_lower: + score += 180 + elif query_lower in barcode_lower: + score += 40 + + # Description match + if item.description: + desc_lower = item.description.lower() + if query_lower in desc_lower: + score += 30 + + # Category match + if item.category: + cat_lower = item.category.lower() + if query_lower in cat_lower: + score += 20 + + # Type match + if item.type: + type_lower = item.type.lower() + if query_lower in type_lower: + score += 15 + + # OCR text / specs + if item.ocr_text: + ocr_lower = item.ocr_text.lower() + if query_lower in ocr_lower: + score += 10 + + if score > 0: + scored_items.append((item, score)) + + # Sort by score (descending), then by name for consistency + scored_items.sort(key=lambda x: (-x[1], x[0].name or "")) + + # Return top 50 results + results = [item for item, score in scored_items[:50]] + return results + @router.get("/stats") def read_item_stats( db: Session = Depends(get_db), @@ -243,6 +331,57 @@ def update_item( db.refresh(db_item) return db_item +@router.patch("/{item_id}", response_model=schemas.Item) +def update_item_quantity( + item_id: int, + body: dict, + db: Session = Depends(get_db), + current_user: auth.TokenData = Depends(auth.get_current_user) +): + """[PHASE-5-T4] Update item quantity via PATCH. Supports direct quantity adjustment without modal.""" + db_item = db.query(models.Item).filter(models.Item.id == item_id).first() + if not db_item: + raise HTTPException(status_code=404, detail="Item not found") + + # Extract and validate quantity from body + if "quantity" not in body: + raise HTTPException(status_code=400, detail="Missing 'quantity' field") + + try: + new_quantity = int(body["quantity"]) + except (ValueError, TypeError): + raise HTTPException(status_code=400, detail="Quantity must be an integer") + + if new_quantity < 0: + raise HTTPException(status_code=400, detail="Quantity must be non-negative") + + # Record old quantity for audit log + old_quantity = db_item.quantity + quantity_delta = new_quantity - old_quantity + + # Update quantity + db_item.quantity = new_quantity + db.commit() + db.refresh(db_item) + + # Create audit log entry + audit = models.AuditLog( + user_id=current_user.sub, + action="UPDATE_QUANTITY", + target_item_id=db_item.id, + target_item_name=db_item.name, + target_item_pn=db_item.part_number, + target_item_barcode=db_item.barcode, + quantity_change=quantity_delta, + details=f"Quantity: {old_quantity} → {new_quantity}" + ) + db.add(audit) + db.commit() + + log.info(f"[PATCH /items/{item_id}] USER[{current_user.sub}] Updated quantity: {old_quantity} → {new_quantity}") + + return db_item + @router.delete("/{item_id}") def delete_item( item_id: int, diff --git a/frontend/components/inventory/QuantityDisplay.tsx b/frontend/components/inventory/QuantityDisplay.tsx new file mode 100644 index 00000000..7848fe2d --- /dev/null +++ b/frontend/components/inventory/QuantityDisplay.tsx @@ -0,0 +1,131 @@ +'use client'; + +import { useState, useRef, useEffect } from 'react'; +import { Plus, Minus } from 'lucide-react'; + +interface QuantityDisplayProps { + itemId: string; + currentQuantity: number; + onQuantityChange: (newQty: number) => Promise; +} + +export default function QuantityDisplay({ + itemId, + currentQuantity, + onQuantityChange, +}: QuantityDisplayProps) { + const [isEditing, setIsEditing] = useState(false); + const [localQuantity, setLocalQuantity] = useState(currentQuantity); + const [isLoading, setIsLoading] = useState(false); + const inputRef = useRef(null); + + useEffect(() => { + setLocalQuantity(currentQuantity); + }, [currentQuantity]); + + useEffect(() => { + if (isEditing && inputRef.current) { + inputRef.current.focus(); + inputRef.current.select(); + } + }, [isEditing]); + + const handleTapToEdit = () => { + setIsEditing(true); + }; + + const handleIncrement = () => { + setLocalQuantity(prev => Math.max(0, prev + 1)); + }; + + const handleDecrement = () => { + setLocalQuantity(prev => Math.max(0, prev - 1)); + }; + + const handleInputChange = (e: React.ChangeEvent) => { + const value = e.target.value; + if (value === '' || /^\d+$/.test(value)) { + setLocalQuantity(value === '' ? 0 : parseInt(value, 10)); + } + }; + + const handleCommit = async () => { + if (localQuantity === currentQuantity) { + setIsEditing(false); + return; + } + + try { + setIsLoading(true); + await onQuantityChange(localQuantity); + setIsEditing(false); + } catch (error) { + // Revert on error + setLocalQuantity(currentQuantity); + setIsEditing(false); + } finally { + setIsLoading(false); + } + }; + + const handleCancel = () => { + setLocalQuantity(currentQuantity); + setIsEditing(false); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + handleCommit(); + } else if (e.key === 'Escape') { + handleCancel(); + } + }; + + if (isEditing) { + return ( +
+ + + + + +
+ ); + } + + return ( + + ); +} diff --git a/frontend/hooks/useQuantityAdjustment.ts b/frontend/hooks/useQuantityAdjustment.ts new file mode 100644 index 00000000..c456f077 --- /dev/null +++ b/frontend/hooks/useQuantityAdjustment.ts @@ -0,0 +1,87 @@ +import { useState, useCallback, useRef } from 'react'; +import axios from 'axios'; + +interface UseQuantityAdjustmentResult { + quantity: number; + isLoading: boolean; + error: string | null; + adjustQuantity: (newQuantity: number) => Promise; + resetError: () => void; +} + +export function useQuantityAdjustment( + itemId: string, + initialQuantity: number +): UseQuantityAdjustmentResult { + const [quantity, setQuantity] = useState(initialQuantity); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const debounceTimerRef = useRef(null); + + const adjustQuantity = useCallback( + async (newQuantity: number) => { + // Validate quantity + if (typeof newQuantity !== 'number' || newQuantity < 0 || !Number.isInteger(newQuantity)) { + setError('Quantity must be a non-negative integer'); + return; + } + + // Clear any pending debounce timer + if (debounceTimerRef.current) { + clearTimeout(debounceTimerRef.current); + } + + // Optimistic update + const previousQuantity = quantity; + setQuantity(newQuantity); + setError(null); + setIsLoading(true); + + try { + // Debounce: wait 100ms before sending to API + await new Promise(resolve => { + debounceTimerRef.current = setTimeout(resolve, 100); + }); + + // Make API call + const backendUrl = process.env.NEXT_PUBLIC_BACKEND_URL || 'http://localhost:8906'; + const response = await axios.patch(`${backendUrl}/items/${itemId}`, { + quantity: newQuantity, + }); + + // Confirm the update with response data + if (response.data && typeof response.data.quantity === 'number') { + setQuantity(response.data.quantity); + } + } catch (err) { + // Revert optimistic update on error + setQuantity(previousQuantity); + + const errorMessage = + err instanceof Error + ? err.message + : axios.isAxiosError(err) + ? err.response?.data?.detail || 'Failed to update quantity' + : 'Failed to update quantity'; + + setError(errorMessage); + throw err; + } finally { + setIsLoading(false); + } + }, + [itemId, quantity] + ); + + const resetError = useCallback(() => { + setError(null); + }, []); + + return { + quantity, + isLoading, + error, + adjustQuantity, + resetError, + }; +}