feat(5-plan-02-t3,t4): create useItemSearch hook and integrate search button into inventory page
This commit is contained in:
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 };
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user