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:
2026-04-22 17:42:04 +03:00
parent d7dcd051fe
commit 9fd20fff7c
3 changed files with 357 additions and 0 deletions

View 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>
);
}

View 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,
};
}