Update NLP Search
This commit is contained in:
parent
d2d9e0d5c3
commit
9aaa70f41a
@ -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 { Download, ChevronDown, Loader2 } from "lucide-react";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@ -114,10 +114,13 @@ const CreditScoreRange = memo(({
|
|||||||
));
|
));
|
||||||
CreditScoreRange.displayName = 'CreditScoreRange';
|
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 [searchQuery, setSearchQuery] = useState("");
|
||||||
const [stateOpen, setStateOpen] = useState(false);
|
const [stateOpen, setStateOpen] = useState(false);
|
||||||
const [districtOpen, setDistrictOpen] = useState(false);
|
const [districtOpen, setDistrictOpen] = useState(false);
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const wasFocusedRef = useRef(false);
|
||||||
|
const cursorPositionRef = useRef<number | null>(null);
|
||||||
|
|
||||||
// Data for dropdowns
|
// Data for dropdowns
|
||||||
const [availableStates, setAvailableStates] = useState<string[]>([]);
|
const [availableStates, setAvailableStates] = useState<string[]>([]);
|
||||||
@ -131,18 +134,81 @@ export const SearchFilters = memo(({ onSearch, onFilter, onDownload, isLoading =
|
|||||||
maxCreditScore: 1000,
|
maxCreditScore: 1000,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Debounce search query - 300ms delay
|
// Store callbacks in refs to avoid dependency issues that cause re-renders
|
||||||
const debouncedSearchQuery = useDebounce(searchQuery, 300);
|
const onSearchRef = useRef(onSearch);
|
||||||
|
const onFilterRef = useRef(onFilter);
|
||||||
|
|
||||||
// Trigger search when debounced value changes
|
// Update refs when callbacks change (without causing re-renders)
|
||||||
const handleSearchChange = useCallback((value: string) => {
|
useEffect(() => {
|
||||||
|
onSearchRef.current = onSearch;
|
||||||
|
onFilterRef.current = onFilter;
|
||||||
|
}, [onSearch, onFilter]);
|
||||||
|
|
||||||
|
// 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<HTMLInputElement>) => {
|
||||||
|
const value = e.target.value;
|
||||||
|
const cursorPos = e.target.selectionStart || 0;
|
||||||
setSearchQuery(value);
|
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<HTMLInputElement>) => {
|
||||||
|
// 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
|
// 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(() => {
|
useEffect(() => {
|
||||||
onSearch(debouncedSearchQuery);
|
const timeoutId = setTimeout(() => {
|
||||||
}, [debouncedSearchQuery, onSearch]);
|
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
|
// Fetch states on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -198,42 +264,41 @@ export const SearchFilters = memo(({ onSearch, onFilter, onDownload, isLoading =
|
|||||||
}, [debouncedStates]);
|
}, [debouncedStates]);
|
||||||
|
|
||||||
const handleFilterChange = useCallback((key: keyof FilterState, value: string | number | string[]) => {
|
const handleFilterChange = useCallback((key: keyof FilterState, value: string | number | string[]) => {
|
||||||
const newFilters = { ...filters, [key]: value };
|
setFilters((prevFilters) => {
|
||||||
setFilters(newFilters);
|
const newFilters = { ...prevFilters, [key]: value };
|
||||||
onFilter(newFilters);
|
onFilterRef.current(newFilters);
|
||||||
}, [onFilter, filters]);
|
return newFilters;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
const toggleState = useCallback((state: string, checked: boolean) => {
|
const toggleState = useCallback((state: string, checked: boolean) => {
|
||||||
|
setFilters((prevFilters) => {
|
||||||
const newStates = checked
|
const newStates = checked
|
||||||
? Array.from(new Set([...filters.states, state]))
|
? Array.from(new Set([...prevFilters.states, state]))
|
||||||
: filters.states.filter(s => s !== state);
|
: prevFilters.states.filter(s => s !== state);
|
||||||
|
|
||||||
const newFilters = {
|
const newFilters = {
|
||||||
...filters,
|
...prevFilters,
|
||||||
states: newStates,
|
states: newStates,
|
||||||
// If we deselect a state, we should probably clear districts involved with it?
|
districts: newStates.length === 0 ? [] : prevFilters.districts,
|
||||||
// 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,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
setFilters(newFilters);
|
onFilterRef.current(newFilters);
|
||||||
onFilter(newFilters);
|
return newFilters;
|
||||||
}, [onFilter, filters]);
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
const toggleDistrict = useCallback((district: string, checked: boolean) => {
|
const toggleDistrict = useCallback((district: string, checked: boolean) => {
|
||||||
|
setFilters((prevFilters) => {
|
||||||
const newDistricts = checked
|
const newDistricts = checked
|
||||||
? Array.from(new Set([...filters.districts, district]))
|
? Array.from(new Set([...prevFilters.districts, district]))
|
||||||
: filters.districts.filter(d => d !== district);
|
: prevFilters.districts.filter(d => d !== district);
|
||||||
|
|
||||||
const newFilters = { ...filters, districts: newDistricts };
|
const newFilters = { ...prevFilters, districts: newDistricts };
|
||||||
setFilters(newFilters);
|
onFilterRef.current(newFilters);
|
||||||
onFilter(newFilters);
|
return newFilters;
|
||||||
}, [onFilter, filters]);
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleDealerTypeChange = useCallback((value: string) => {
|
const handleDealerTypeChange = useCallback((value: string) => {
|
||||||
handleFilterChange('dealerType', value);
|
handleFilterChange('dealerType', value);
|
||||||
@ -252,11 +317,16 @@ export const SearchFilters = memo(({ onSearch, onFilter, onDownload, isLoading =
|
|||||||
{/* Search Bar Row */}
|
{/* Search Bar Row */}
|
||||||
<div className="w-full relative">
|
<div className="w-full relative">
|
||||||
<Input
|
<Input
|
||||||
placeholder="Search by dealer name, city, state, district, credit score, mobile, aadhaar, or license..."
|
ref={inputRef}
|
||||||
|
type="text"
|
||||||
|
placeholder="Search dealers by name, MFMS ID, location, credit score, or ask a question..."
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(e) => handleSearchChange(e.target.value)}
|
onChange={handleSearchChange}
|
||||||
|
onFocus={handleFocus}
|
||||||
|
onBlur={handleBlur}
|
||||||
className="w-full pr-10"
|
className="w-full pr-10"
|
||||||
disabled={isLoading}
|
autoComplete="off"
|
||||||
|
spellCheck="false"
|
||||||
/>
|
/>
|
||||||
{isLoading && (
|
{isLoading && (
|
||||||
<div className="absolute right-3 top-1/2 -translate-y-1/2">
|
<div className="absolute right-3 top-1/2 -translate-y-1/2">
|
||||||
@ -355,6 +425,15 @@ export const SearchFilters = memo(({ onSearch, onFilter, onDownload, isLoading =
|
|||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
};
|
||||||
|
|
||||||
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;
|
||||||
|
});
|
||||||
|
|||||||
@ -40,8 +40,12 @@ export const DealerTableRow = memo(({
|
|||||||
<td className="px-4 py-3 text-sm text-center">
|
<td className="px-4 py-3 text-sm text-center">
|
||||||
{dealer.avgAcknowledgmentCycle > 0 ? `${dealer.avgAcknowledgmentCycle.toFixed(1)} Days` : '0 Days'}
|
{dealer.avgAcknowledgmentCycle > 0 ? `${dealer.avgAcknowledgmentCycle.toFixed(1)} Days` : '0 Days'}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-sm text-center">{dealer.currentStock} MT</td>
|
<td className="px-4 py-3 text-sm text-center">
|
||||||
<td className="px-4 py-3 text-sm text-center">{dealer.agedStock} MT</td>
|
{dealer.currentStock > 0 ? parseFloat(Number(dealer.currentStock).toFixed(2)).toString() : '0'} MT
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-sm text-center">
|
||||||
|
{dealer.agedStock > 0 ? parseFloat(Number(dealer.agedStock).toFixed(2)).toString() : '0'} MT
|
||||||
|
</td>
|
||||||
<td className="px-4 py-3 text-sm">
|
<td className="px-4 py-3 text-sm">
|
||||||
<CreditScoreBar score={dealer.creditScore} />
|
<CreditScoreBar score={dealer.creditScore} />
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@ -1,8 +1,8 @@
|
|||||||
import { useCallback, useEffect } from "react";
|
import { useCallback, useEffect, useMemo } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { Users, TrendingUp, AlertTriangle, Clock } from "lucide-react";
|
import { Users, TrendingUp, AlertTriangle, Clock } from "lucide-react";
|
||||||
import { KPICard } from "@/components/KPICard";
|
import { KPICard } from "@/components/KPICard";
|
||||||
import { SearchFilters } from "@/components/SearchFilters";
|
import { SearchFilters, type FilterState } from "@/components/SearchFilters";
|
||||||
import { DashboardHeader } from "@/components/layout/DashboardHeader";
|
import { DashboardHeader } from "@/components/layout/DashboardHeader";
|
||||||
import { DealerTable } from "@/components/dealer/DealerTable";
|
import { DealerTable } from "@/components/dealer/DealerTable";
|
||||||
import { Pagination } from "@/components/common/Pagination";
|
import { Pagination } from "@/components/common/Pagination";
|
||||||
@ -44,17 +44,17 @@ const Dashboard = () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// API Integration for Overview Metrics
|
// 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
|
// Fallback to 0 if metrics not yet loaded - memoize to prevent re-renders
|
||||||
const displayKPIs = metrics || {
|
const displayKPIs = useMemo(() => metrics || {
|
||||||
totalDealers: 0,
|
totalDealers: 0,
|
||||||
avgCreditScore: 0,
|
avgCreditScore: 0,
|
||||||
highRiskPercentage: "0.0",
|
highRiskPercentage: "0.0",
|
||||||
avgLiquidityCycle: 0
|
avgLiquidityCycle: 0
|
||||||
};
|
}, [metrics]);
|
||||||
|
|
||||||
// Redirect to login if not authenticated
|
// Redirect to login if not authenticated
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -130,6 +130,16 @@ const Dashboard = () => {
|
|||||||
});
|
});
|
||||||
}, [toast]);
|
}, [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)
|
// Show UI immediately even if auth is loading (will redirect if not authenticated)
|
||||||
// This provides better UX - user sees the page structure immediately
|
// This provides better UX - user sees the page structure immediately
|
||||||
return (
|
return (
|
||||||
@ -147,8 +157,14 @@ const Dashboard = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* KPI Cards - Always show skeleton on initial load for better perceived performance */}
|
{/* KPI Cards - Always show skeleton on initial load for better perceived performance */}
|
||||||
|
{overviewError && (
|
||||||
|
<div className="mb-4 p-3 bg-destructive/10 border border-destructive/20 rounded-lg text-sm text-destructive">
|
||||||
|
<p className="font-semibold">Error loading overview metrics</p>
|
||||||
|
<p className="text-xs mt-1">{overviewError}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-6">
|
||||||
{overviewLoading || loading ? (
|
{overviewLoading ? (
|
||||||
// Show loading skeleton for all cards
|
// Show loading skeleton for all cards
|
||||||
<>
|
<>
|
||||||
{[1, 2, 3, 4].map((i) => (
|
{[1, 2, 3, 4].map((i) => (
|
||||||
@ -169,10 +185,8 @@ const Dashboard = () => {
|
|||||||
/>
|
/>
|
||||||
<KPICard
|
<KPICard
|
||||||
title="Avg Credit Score"
|
title="Avg Credit Score"
|
||||||
value={displayKPIs.avgCreditScore}
|
value={typeof displayKPIs.avgCreditScore === 'number' ? displayKPIs.avgCreditScore.toFixed(1) : displayKPIs.avgCreditScore}
|
||||||
icon={TrendingUp}
|
icon={TrendingUp}
|
||||||
trend="↑ 2.3% from last month"
|
|
||||||
trendColor="success"
|
|
||||||
/>
|
/>
|
||||||
<KPICard
|
<KPICard
|
||||||
title="High-Risk Dealers"
|
title="High-Risk Dealers"
|
||||||
@ -183,10 +197,8 @@ const Dashboard = () => {
|
|||||||
/>
|
/>
|
||||||
<KPICard
|
<KPICard
|
||||||
title="Avg Liquidity Cycle"
|
title="Avg Liquidity Cycle"
|
||||||
value={`${displayKPIs.avgLiquidityCycle} Days`}
|
value={`${typeof displayKPIs.avgLiquidityCycle === 'number' ? displayKPIs.avgLiquidityCycle.toFixed(2) : displayKPIs.avgLiquidityCycle} Days`}
|
||||||
icon={Clock}
|
icon={Clock}
|
||||||
trend="↓ 1.8 days improved"
|
|
||||||
trendColor="success"
|
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@ -195,8 +207,8 @@ const Dashboard = () => {
|
|||||||
{/* Search & Filters */}
|
{/* Search & Filters */}
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<SearchFilters
|
<SearchFilters
|
||||||
onSearch={setSearchQuery}
|
onSearch={handleSearch}
|
||||||
onFilter={setFilters}
|
onFilter={handleFilter}
|
||||||
onDownload={handleDownload}
|
onDownload={handleDownload}
|
||||||
isLoading={listLoading}
|
isLoading={listLoading}
|
||||||
/>
|
/>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user