feat(5.1): implement quantity display component and adjustment hook
- Create QuantityDisplay component with tap-to-edit UI + +/- buttons
- Implement useQuantityAdjustment hook with optimistic updates, debouncing
- Add PATCH /items/{itemId} backend endpoint with audit logging
- Components support inline quantity adjustment without modal friction
This commit is contained in:
@@ -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,
|
||||
|
||||
131
frontend/components/inventory/QuantityDisplay.tsx
Normal file
131
frontend/components/inventory/QuantityDisplay.tsx
Normal file
@@ -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<void>;
|
||||
}
|
||||
|
||||
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<HTMLInputElement>(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<HTMLInputElement>) => {
|
||||
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<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleCommit();
|
||||
} else if (e.key === 'Escape') {
|
||||
handleCancel();
|
||||
}
|
||||
};
|
||||
|
||||
if (isEditing) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 bg-slate-900/50 rounded-lg px-3 py-2">
|
||||
<button
|
||||
onClick={handleDecrement}
|
||||
disabled={isLoading}
|
||||
aria-label="Decrease quantity"
|
||||
className="p-1 hover:bg-slate-800 rounded text-secondary hover:text-primary transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Minus size={16} />
|
||||
</button>
|
||||
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
value={localQuantity}
|
||||
onChange={handleInputChange}
|
||||
onBlur={handleCommit}
|
||||
onKeyDown={handleKeyDown}
|
||||
disabled={isLoading}
|
||||
aria-label="Quantity"
|
||||
className="w-12 text-center bg-transparent text-primary font-medium focus:outline-none disabled:opacity-50"
|
||||
/>
|
||||
|
||||
<button
|
||||
onClick={handleIncrement}
|
||||
disabled={isLoading}
|
||||
aria-label="Increase quantity"
|
||||
className="p-1 hover:bg-slate-800 rounded text-secondary hover:text-primary transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Plus size={16} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleTapToEdit}
|
||||
disabled={isLoading}
|
||||
aria-label={`Quantity: ${currentQuantity}. Tap to edit`}
|
||||
className="text-lg font-medium text-primary hover:bg-slate-900/30 px-3 py-2 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{currentQuantity}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
87
frontend/hooks/useQuantityAdjustment.ts
Normal file
87
frontend/hooks/useQuantityAdjustment.ts
Normal file
@@ -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<void>;
|
||||
resetError: () => void;
|
||||
}
|
||||
|
||||
export function useQuantityAdjustment(
|
||||
itemId: string,
|
||||
initialQuantity: number
|
||||
): UseQuantityAdjustmentResult {
|
||||
const [quantity, setQuantity] = useState(initialQuantity);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const debounceTimerRef = useRef<NodeJS.Timeout | null>(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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user