feat(5-plan-02-t3,t4): create useItemSearch hook and integrate search button into inventory page

This commit is contained in:
2026-04-22 17:44:11 +03:00
parent 0138f04f6e
commit b28eb49fe7
2 changed files with 136 additions and 75 deletions

View File

@@ -8,6 +8,8 @@ import Scanner from '@/components/Scanner';
import StatCard from '@/components/StatCard'; import StatCard from '@/components/StatCard';
import InventoryTable from '@/components/InventoryTable'; import InventoryTable from '@/components/InventoryTable';
import FilterBar from '@/components/FilterBar'; import FilterBar from '@/components/FilterBar';
import SearchModal from '@/components/inventory/SearchModal';
import QuantityAdjustmentModal from '@/components/inventory/QuantityAdjustmentModal';
import { useInventoryFilter } from '@/hooks/useInventoryFilter'; import { useInventoryFilter } from '@/hooks/useInventoryFilter';
import { toast } from 'react-hot-toast'; import { toast } from 'react-hot-toast';
import { import {
@@ -80,6 +82,11 @@ export default function InventoryPage() {
const [showBoxManager, setShowBoxManager] = useState(false); const [showBoxManager, setShowBoxManager] = useState(false);
const [selectedBoxLabel, setSelectedBoxLabel] = useState<string | null>(null); const [selectedBoxLabel, setSelectedBoxLabel] = useState<string | null>(null);
// Search Modal State
const [showSearchModal, setShowSearchModal] = useState(false);
const [selectedSearchItem, setSelectedSearchItem] = useState<Item | null>(null);
const [showQuantityModal, setShowQuantityModal] = useState(false);
useEffect(() => { useEffect(() => {
setMounted(true); setMounted(true);
const savedUser = localStorage.getItem('inventory_user'); const savedUser = localStorage.getItem('inventory_user');
@@ -238,6 +245,16 @@ export default function InventoryPage() {
} }
}, [inventory]); }, [inventory]);
const handleSearchItemSelect = (item: Item) => {
setSelectedSearchItem(item);
setShowQuantityModal(true);
};
const handleQuantityModalClose = () => {
setShowQuantityModal(false);
setSelectedSearchItem(null);
};
// Extract unique item types and box labels for suggestions // Extract unique item types and box labels for suggestions
const existingTypes = Array.from(new Set(inventory.map(i => i.type).filter(Boolean))).sort() as string[]; const existingTypes = Array.from(new Set(inventory.map(i => i.type).filter(Boolean))).sort() as string[];
const existingBoxes = Array.from(new Set(inventory.map(i => i.box_label).filter(Boolean))).sort() as string[]; const existingBoxes = Array.from(new Set(inventory.map(i => i.box_label).filter(Boolean))).sort() as string[];
@@ -263,8 +280,15 @@ export default function InventoryPage() {
<p className="text-xs md:text-sm text-secondary font-normal mt-1 tracking-widest">Enterprise Stock Overview</p> <p className="text-xs md:text-sm text-secondary font-normal mt-1 tracking-widest">Enterprise Stock Overview</p>
</div> </div>
<button <button
onClick={() => setShowBoxManager(true)} onClick={() => setShowSearchModal(true)}
className="ml-auto p-3 bg-surface border border-slate-800 text-secondary rounded-2xl hover:text-primary transition-all active:scale-95 shadow-xl" className="ml-auto p-3 bg-surface border border-slate-800 text-secondary rounded-2xl hover:text-primary transition-all active:scale-95 shadow-xl"
title="Search inventory"
>
<Search size={20} />
</button>
<button
onClick={() => setShowBoxManager(true)}
className="p-3 bg-surface border border-slate-800 text-secondary rounded-2xl hover:text-primary transition-all active:scale-95 shadow-xl"
title="Manage Boxes" title="Manage Boxes"
> >
<Layout size={20} /> <Layout size={20} />
@@ -784,6 +808,20 @@ export default function InventoryPage() {
</div> </div>
</div> </div>
)} )}
{/* Search Modal */}
<SearchModal
isOpen={showSearchModal}
onClose={() => setShowSearchModal(false)}
onSelectItem={handleSearchItemSelect}
/>
{/* Quantity Adjustment Modal */}
<QuantityAdjustmentModal
item={selectedSearchItem}
isOpen={showQuantityModal}
onClose={handleQuantityModalClose}
/>
</PageShell> </PageShell>
); );
} }

View File

@@ -1,89 +1,112 @@
'use client'; 'use client';
import { useState, useCallback } from 'react'; import { useState, useCallback, useRef, useEffect } from 'react';
export interface SearchResult { interface SearchItem {
category?: string; id: number;
type?: string; name: string;
description?: string; part_number?: string;
notes?: string; barcode?: string;
quantity: number;
} }
export interface SearchState { interface UseItemSearchResult {
isSearching: boolean; results: SearchItem[];
searchError: string | null; isLoading: boolean;
searchResults: SearchResult | null; error: string | null;
searchStatus: 'idle' | 'searching' | 'success' | 'timeout' | 'error' | 'no_results' | 'skipped';
retryCount: number;
} }
export function useItemSearch(options: { maxRetries?: number; timeout?: number } = {}) { export function useItemSearch(
const { maxRetries = 2, timeout = 30000 } = options; query: string,
enabled: boolean = true
): UseItemSearchResult {
const [results, setResults] = useState<SearchItem[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const debounceTimerRef = useRef<NodeJS.Timeout | null>(null);
const cacheRef = useRef<Map<string, SearchItem[]>>(new Map());
const [state, setState] = useState<SearchState>({ const performSearch = useCallback(async (searchQuery: string) => {
isSearching: false, // Client-side validation: require min 2 chars
searchError: null, if (!searchQuery || searchQuery.length < 2) {
searchResults: null, setResults([]);
searchStatus: 'idle', setError(null);
retryCount: 0, return;
}); }
const performSearch = useCallback( // Check cache first
async (partNumber: string, category: string): Promise<SearchResult | null> => { if (cacheRef.current.has(searchQuery)) {
if (!partNumber || !category) { setResults(cacheRef.current.get(searchQuery) || []);
setState(prev => ({ ...prev, searchStatus: 'skipped', searchError: null })); setError(null);
return 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');
} }
setState(prev => ({ ...prev, isSearching: true, searchStatus: 'searching', searchError: null, retryCount: 0 })); const data = await response.json();
const items = data || [];
try { // Cache the results
const controller = new AbortController(); cacheRef.current.set(searchQuery, items);
const timeoutId = setTimeout(() => controller.abort(), timeout); setResults(items);
setError(null);
const response = await fetch('/api/onboarding/search', { } catch (err) {
method: 'POST', const errorMsg = err instanceof Error ? err.message : 'Search failed';
headers: { 'Content-Type': 'application/json' }, setError(errorMsg);
body: JSON.stringify({ partNumber, category }), setResults([]);
signal: controller.signal, } finally {
}); setIsLoading(false);
}
clearTimeout(timeoutId);
if (!response.ok) throw new Error(`Search failed: ${response.status}`);
const results = await response.json();
setState(prev => ({ ...prev, isSearching: false, searchStatus: 'success', searchResults: results }));
return results;
} catch (error) {
const errorMsg = error instanceof Error ? error.message : 'Search failed';
setState(prev => ({
...prev,
isSearching: false,
searchStatus: errorMsg.includes('abort') ? 'timeout' : 'error',
searchError: errorMsg,
}));
return null;
}
},
[timeout]
);
const retrySearch = useCallback(
async (partNumber: string, category: string) => {
if (state.retryCount >= maxRetries) {
setState(prev => ({ ...prev, searchStatus: 'error' }));
return null;
}
setState(prev => ({ ...prev, retryCount: prev.retryCount + 1 }));
return performSearch(partNumber, category);
},
[state.retryCount, maxRetries, performSearch]
);
const skipSearch = useCallback(() => {
setState(prev => ({ ...prev, searchStatus: 'skipped', isSearching: false }));
}, []); }, []);
return { state, performSearch, retrySearch, skipSearch }; // Debounced search on query change
useEffect(() => {
if (!enabled) {
setResults([]);
setError(null);
return;
}
// Clear previous debounce timer
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
}
// Set new debounce timer (300ms)
debounceTimerRef.current = setTimeout(() => {
performSearch(query);
}, 300);
return () => {
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
}
};
}, [query, enabled, performSearch]);
// Cleanup cache on unmount
useEffect(() => {
return () => {
cacheRef.current.clear();
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
}
};
}, []);
return { results, isLoading, error };
} }