diff --git a/src/components/SearchFilters.tsx b/src/components/SearchFilters.tsx index 40759de..f9a821e 100644 --- a/src/components/SearchFilters.tsx +++ b/src/components/SearchFilters.tsx @@ -1,4 +1,4 @@ -import { memo, useState, useCallback, useEffect, useMemo } from "react"; +import { memo, useState, useCallback, useEffect, useLayoutEffect, useMemo, useRef } from "react"; import { Download, ChevronDown, Loader2 } from "lucide-react"; import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; @@ -114,10 +114,13 @@ const CreditScoreRange = memo(({ )); CreditScoreRange.displayName = 'CreditScoreRange'; -export const SearchFilters = memo(({ onSearch, onFilter, onDownload, isLoading = false }: SearchFiltersProps) => { +const SearchFiltersComponent = ({ onSearch, onFilter, onDownload, isLoading = false }: SearchFiltersProps) => { const [searchQuery, setSearchQuery] = useState(""); const [stateOpen, setStateOpen] = useState(false); const [districtOpen, setDistrictOpen] = useState(false); + const inputRef = useRef(null); + const wasFocusedRef = useRef(false); + const cursorPositionRef = useRef(null); // Data for dropdowns const [availableStates, setAvailableStates] = useState([]); @@ -131,18 +134,81 @@ export const SearchFilters = memo(({ onSearch, onFilter, onDownload, isLoading = maxCreditScore: 1000, }); - // Debounce search query - 300ms delay - const debouncedSearchQuery = useDebounce(searchQuery, 300); + // Store callbacks in refs to avoid dependency issues that cause re-renders + const onSearchRef = useRef(onSearch); + const onFilterRef = useRef(onFilter); + + // Update refs when callbacks change (without causing re-renders) + useEffect(() => { + onSearchRef.current = onSearch; + onFilterRef.current = onFilter; + }, [onSearch, onFilter]); - // Trigger search when debounced value changes - const handleSearchChange = useCallback((value: string) => { + // Debounce search query - 500ms delay to reduce API calls + const debouncedSearchQuery = useDebounce(searchQuery, 500); + + // Handle input change - update local state immediately + const handleSearchChange = useCallback((e: React.ChangeEvent) => { + const value = e.target.value; + const cursorPos = e.target.selectionStart || 0; setSearchQuery(value); + // Store cursor position and focus state + cursorPositionRef.current = cursorPos; + wasFocusedRef.current = true; + }, []); + + // Handle focus events + const handleFocus = useCallback(() => { + wasFocusedRef.current = true; + }, []); + + const handleBlur = useCallback((e: React.FocusEvent) => { + // Only mark as not focused if focus is actually leaving (not just moving within the component) + // Use a small delay to check if focus moved to another element in the same component + setTimeout(() => { + if (document.activeElement !== e.target && document.activeElement !== inputRef.current) { + wasFocusedRef.current = false; + cursorPositionRef.current = null; + } + }, 0); }, []); // Effect to call onSearch when debounced value changes + // Using ref to avoid dependency on onSearch which would cause re-renders + // Use setTimeout to batch the call and prevent focus loss useEffect(() => { - onSearch(debouncedSearchQuery); - }, [debouncedSearchQuery, onSearch]); + const timeoutId = setTimeout(() => { + onSearchRef.current(debouncedSearchQuery); + }, 0); + return () => clearTimeout(timeoutId); + }, [debouncedSearchQuery]); + + // Restore focus and cursor position after re-renders using useLayoutEffect + // This runs synchronously before the browser paints, preventing visible focus loss + useLayoutEffect(() => { + if (wasFocusedRef.current && inputRef.current) { + const input = inputRef.current; + const isCurrentlyFocused = document.activeElement === input; + + // Restore focus if we were focused but aren't anymore + if (!isCurrentlyFocused) { + input.focus(); + } + + // Restore cursor position if we have one stored + if (cursorPositionRef.current !== null) { + const pos = cursorPositionRef.current; + const maxPos = input.value.length; + const cursorPos = Math.min(pos, maxPos); + // Use requestAnimationFrame for better timing + requestAnimationFrame(() => { + if (inputRef.current && wasFocusedRef.current) { + inputRef.current.setSelectionRange(cursorPos, cursorPos); + } + }); + } + } + }, [searchQuery, isLoading]); // Run when these change, as they're most likely to cause re-renders // Fetch states on mount useEffect(() => { @@ -198,42 +264,41 @@ export const SearchFilters = memo(({ onSearch, onFilter, onDownload, isLoading = }, [debouncedStates]); const handleFilterChange = useCallback((key: keyof FilterState, value: string | number | string[]) => { - const newFilters = { ...filters, [key]: value }; - setFilters(newFilters); - onFilter(newFilters); - }, [onFilter, filters]); + setFilters((prevFilters) => { + const newFilters = { ...prevFilters, [key]: value }; + onFilterRef.current(newFilters); + return newFilters; + }); + }, []); const toggleState = useCallback((state: string, checked: boolean) => { - const newStates = checked - ? Array.from(new Set([...filters.states, state])) - : filters.states.filter(s => s !== state); + setFilters((prevFilters) => { + const newStates = checked + ? Array.from(new Set([...prevFilters.states, state])) + : prevFilters.states.filter(s => s !== state); - const newFilters = { - ...filters, - states: newStates, - // If we deselect a state, we should probably clear districts involved with it? - // But since we refetch districts based on new states, valid districts will be returned. - // We might want to filter out selected districts that are no longer in the available list. - // However user might want to keep them if they are still valid. - // For now, let's keep it simple and just update states. The district list will update visually. - // If we want to be strict, we can filter districts against the new availableDistricts list in the useEffect, - // but that requires another effect or logic. - districts: newStates.length === 0 ? [] : filters.districts, - }; + const newFilters = { + ...prevFilters, + states: newStates, + districts: newStates.length === 0 ? [] : prevFilters.districts, + }; - setFilters(newFilters); - onFilter(newFilters); - }, [onFilter, filters]); + onFilterRef.current(newFilters); + return newFilters; + }); + }, []); const toggleDistrict = useCallback((district: string, checked: boolean) => { - const newDistricts = checked - ? Array.from(new Set([...filters.districts, district])) - : filters.districts.filter(d => d !== district); + setFilters((prevFilters) => { + const newDistricts = checked + ? Array.from(new Set([...prevFilters.districts, district])) + : prevFilters.districts.filter(d => d !== district); - const newFilters = { ...filters, districts: newDistricts }; - setFilters(newFilters); - onFilter(newFilters); - }, [onFilter, filters]); + const newFilters = { ...prevFilters, districts: newDistricts }; + onFilterRef.current(newFilters); + return newFilters; + }); + }, []); const handleDealerTypeChange = useCallback((value: string) => { handleFilterChange('dealerType', value); @@ -252,11 +317,16 @@ export const SearchFilters = memo(({ onSearch, onFilter, onDownload, isLoading = {/* Search Bar Row */}
handleSearchChange(e.target.value)} + onChange={handleSearchChange} + onFocus={handleFocus} + onBlur={handleBlur} className="w-full pr-10" - disabled={isLoading} + autoComplete="off" + spellCheck="false" /> {isLoading && (
@@ -355,6 +425,15 @@ export const SearchFilters = memo(({ onSearch, onFilter, onDownload, isLoading =
); -}); +}; -SearchFilters.displayName = 'SearchFilters'; +SearchFiltersComponent.displayName = 'SearchFilters'; + +// Memoize with custom comparison to prevent re-renders +// Only re-render if isLoading changes - callbacks are stored in refs so changes don't matter +// The input uses local state, so re-renders won't affect typing +export const SearchFilters = memo(SearchFiltersComponent, (prevProps, nextProps) => { + // Only re-render if isLoading actually changed (false->true or true->false) + // This is necessary to show/hide the loading spinner + return prevProps.isLoading === nextProps.isLoading; +}); diff --git a/src/components/dealer/DealerTableRow.tsx b/src/components/dealer/DealerTableRow.tsx index 345613c..c335946 100644 --- a/src/components/dealer/DealerTableRow.tsx +++ b/src/components/dealer/DealerTableRow.tsx @@ -40,8 +40,12 @@ export const DealerTableRow = memo(({ {dealer.avgAcknowledgmentCycle > 0 ? `${dealer.avgAcknowledgmentCycle.toFixed(1)} Days` : '0 Days'} - {dealer.currentStock} MT - {dealer.agedStock} MT + + {dealer.currentStock > 0 ? parseFloat(Number(dealer.currentStock).toFixed(2)).toString() : '0'} MT + + + {dealer.agedStock > 0 ? parseFloat(Number(dealer.agedStock).toFixed(2)).toString() : '0'} MT + diff --git a/src/pages/Dashboard.tsx b/src/pages/Dashboard.tsx index cbc3724..5de9d2f 100644 --- a/src/pages/Dashboard.tsx +++ b/src/pages/Dashboard.tsx @@ -1,8 +1,8 @@ -import { useCallback, useEffect } from "react"; +import { useCallback, useEffect, useMemo } from "react"; import { useNavigate } from "react-router-dom"; import { Users, TrendingUp, AlertTriangle, Clock } from "lucide-react"; import { KPICard } from "@/components/KPICard"; -import { SearchFilters } from "@/components/SearchFilters"; +import { SearchFilters, type FilterState } from "@/components/SearchFilters"; import { DashboardHeader } from "@/components/layout/DashboardHeader"; import { DealerTable } from "@/components/dealer/DealerTable"; import { Pagination } from "@/components/common/Pagination"; @@ -44,17 +44,17 @@ const Dashboard = () => { ); // API Integration for Overview Metrics - const { metrics, loading: overviewLoading } = useDealerOverview(filters, searchQuery); + const { metrics, loading: overviewLoading, error: overviewError } = useDealerOverview(filters, searchQuery); - const totalPages = Math.ceil(totalCount / ITEMS_PER_PAGE); + const totalPages = useMemo(() => Math.ceil(totalCount / ITEMS_PER_PAGE), [totalCount]); - // Fallback to 0 if metrics not yet loaded - const displayKPIs = metrics || { + // Fallback to 0 if metrics not yet loaded - memoize to prevent re-renders + const displayKPIs = useMemo(() => metrics || { totalDealers: 0, avgCreditScore: 0, highRiskPercentage: "0.0", avgLiquidityCycle: 0 - }; + }, [metrics]); // Redirect to login if not authenticated useEffect(() => { @@ -130,6 +130,16 @@ const Dashboard = () => { }); }, [toast]); + // Memoize search handler to prevent unnecessary re-renders + const handleSearch = useCallback((query: string) => { + setSearchQuery(query); + }, [setSearchQuery]); + + // Memoize filter handler to prevent unnecessary re-renders + const handleFilter = useCallback((newFilters: FilterState) => { + setFilters(newFilters); + }, [setFilters]); + // Show UI immediately even if auth is loading (will redirect if not authenticated) // This provides better UX - user sees the page structure immediately return ( @@ -147,8 +157,14 @@ const Dashboard = () => {
{/* KPI Cards - Always show skeleton on initial load for better perceived performance */} + {overviewError && ( +
+

Error loading overview metrics

+

{overviewError}

+
+ )}
- {overviewLoading || loading ? ( + {overviewLoading ? ( // Show loading skeleton for all cards <> {[1, 2, 3, 4].map((i) => ( @@ -169,10 +185,8 @@ const Dashboard = () => { /> { /> )} @@ -195,8 +207,8 @@ const Dashboard = () => { {/* Search & Filters */}