Compare commits

..

3 Commits

Author SHA1 Message Date
5566371864 changes updated 2026-01-22 17:20:57 +05:30
8e014a8e67 changes updated on jan 22 2026-01-22 15:14:34 +05:30
9aaa70f41a Update NLP Search 2026-01-21 18:21:33 +05:30
26 changed files with 814 additions and 247 deletions

25
.dockerignore Normal file
View File

@ -0,0 +1,25 @@
node_modules
npm-debug.log
.env
.env.local
.env.*.local
.git
.gitignore
README.md
.vscode
.idea
*.swp
*.swo
*~
.DS_Store
Thumbs.db
logs
*.log
dist
dist-ssr
build
*.tmp
*.temp
coverage
.nyc_output

50
Dockerfile Normal file
View File

@ -0,0 +1,50 @@
# Frontend Dockerfile - Multi-stage build
FROM node:20-alpine AS builder
# Set working directory
WORKDIR /app
# Copy package files
COPY package*.json ./
# Install dependencies
RUN npm ci
# Copy application code
COPY . .
# Build the application
# Support both VITE_API_BASE_URL and VITE_API_URL for compatibility
# Defaults to /api (relative URL for nginx proxy in Docker)
ARG VITE_API_BASE_URL=/api
ARG VITE_API_URL
ENV VITE_API_BASE_URL=$VITE_API_BASE_URL
ENV VITE_API_URL=$VITE_API_URL
# Copy .env file if it exists (for local development values)
# This allows using existing .env file during build
COPY .env* ./
# Build using Docker-specific script that skips TypeScript type checking
# TypeScript errors should be fixed in development, but this allows Docker builds to proceed
RUN npm run build:docker
# Production stage
FROM nginx:alpine
# Copy built assets from builder stage
COPY --from=builder /app/dist /usr/share/nginx/html
# Copy custom nginx configuration
COPY nginx.conf /etc/nginx/conf.d/default.conf
# Expose port
EXPOSE 80
# Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:80 || exit 1
# Start nginx
CMD ["nginx", "-g", "daemon off;"]

51
nginx.conf Normal file
View File

@ -0,0 +1,51 @@
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
# Gzip compression
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml+rss application/json application/javascript;
# Security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
# Proxy API requests to backend
location /api {
proxy_pass http://backend:8003;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
proxy_read_timeout 300s;
proxy_connect_timeout 75s;
}
# SPA routing - serve index.html for all routes
location / {
try_files $uri $uri/ /index.html;
}
# Cache static assets
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# Health check endpoint
location /health {
access_log off;
return 200 "healthy\n";
add_header Content-Type text/plain;
}
}

View File

@ -6,6 +6,7 @@
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"build:docker": "vite build",
"lint": "eslint .",
"preview": "vite preview"
},

View File

@ -3,9 +3,29 @@ import axios from 'axios';
/**
* Custom Axios instance with pre-configured base URL and timeout.
* In development, this relies on Vite's proxy (see vite.config.ts) to avoid CORS issues.
* In production (Docker), this uses relative URLs proxied through nginx.
*/
const getBaseURL = () => {
// Use environment variable if set (for Docker builds or development)
// Check both VITE_API_BASE_URL and VITE_API_URL for compatibility
if (import.meta.env.VITE_API_BASE_URL) {
return import.meta.env.VITE_API_BASE_URL;
}
if (import.meta.env.VITE_API_URL) {
// If VITE_API_URL is provided, append /api if not already present
const apiUrl = import.meta.env.VITE_API_URL;
return apiUrl.endsWith('/api') ? apiUrl : `${apiUrl}/api`;
}
// In production/Docker, use relative URL (nginx will proxy to backend)
if (import.meta.env.PROD) {
return '/api';
}
// Development fallback
return 'http://localhost:8003/api';
};
const axiosInstance = axios.create({
baseURL: 'http://localhost:8003/api', // Direct backend URL (requires CORS on backend)
baseURL: getBaseURL(),
timeout: 30000, // Default timeout (30 seconds)
headers: {
'Content-Type': 'application/json',
@ -18,7 +38,7 @@ const axiosInstance = axios.create({
*/
export const createExtendedTimeoutInstance = (timeout: number = 120000) => {
return axios.create({
baseURL: 'http://localhost:8003/api',
baseURL: getBaseURL(),
timeout,
headers: {
'Content-Type': 'application/json',

View File

@ -25,5 +25,6 @@ export const API_ENDPOINTS = {
PRODUCT_WISE_SCORE_TRENDS: (id: string | number) => `/v1/dealerdetails/product-wise-score-trends/${id}`,
PRODUCT_WISE_SCORE_TRENDS_EXPORT: (id: string | number) => `/v1/dealerdetails/product-wise-score-trends/${id}/export`,
COMPARE_DEALER_BUSINESS: (id: string | number) => `/v1/dealerdetails/compare-dealer-business/${id}`,
ACTIVITY_TIMELINE: (id: string | number) => `/v1/dealers/${id}/activity-timeline`,
},
};

View File

@ -258,12 +258,15 @@ export const dealerService = {
/**
* Get compare dealer business data.
* @param id - Dealer ID
* @param manufacturerSchema - Optional: Manufacturer schema to filter by (for "My Business" calculation)
*/
getCompareDealerBusiness: async (id: string | number): Promise<ApiResponse<any>> => {
getCompareDealerBusiness: async (id: string | number, manufacturerSchema?: string): Promise<ApiResponse<any>> => {
try {
const response = await axiosInstance.get<ApiResponse<any>>(
API_ENDPOINTS.DEALERS.COMPARE_DEALER_BUSINESS(id)
);
const url = manufacturerSchema
? `${API_ENDPOINTS.DEALERS.COMPARE_DEALER_BUSINESS(id)}?manufacturer_schema=${manufacturerSchema}`
: API_ENDPOINTS.DEALERS.COMPARE_DEALER_BUSINESS(id);
const response = await axiosInstance.get<ApiResponse<any>>(url);
return response.data;
} catch (error) {
throw error;
@ -292,4 +295,18 @@ export const dealerService = {
throw error;
}
},
/**
* Get activity timeline for a dealer (last SMS, Call, Acknowledgment).
*/
getActivityTimeline: async (id: string | number): Promise<ApiResponse<any>> => {
try {
const response = await axiosInstance.get<ApiResponse<any>>(
API_ENDPOINTS.DEALERS.ACTIVITY_TIMELINE(id)
);
return response.data;
} catch (error) {
throw error;
}
},
};

View File

@ -2,6 +2,7 @@ import { memo } from "react";
import { Card } from "@/components/ui/card";
import type { LucideProps } from "lucide-react";
import type { ForwardRefExoticComponent, RefAttributes } from "react";
import { TrendingUp, TrendingDown } from "lucide-react";
type LucideIcon = ForwardRefExoticComponent<Omit<LucideProps, "ref"> & RefAttributes<SVGSVGElement>>;
@ -11,6 +12,9 @@ interface KPICardProps {
icon: LucideIcon;
trend?: string;
trendColor?: "success" | "danger" | "default";
change?: number | null;
changeLabel?: string | null; // e.g., "vs last month", "vs previous period". If null/undefined, change section is hidden
changeUnit?: string; // e.g., "%", "days"
}
const getTrendColorClass = (trendColor: "success" | "danger" | "default"): string => {
@ -24,7 +28,37 @@ const getTrendColorClass = (trendColor: "success" | "danger" | "default"): strin
}
};
export const KPICard = memo(({ title, value, icon: Icon, trend, trendColor = "default" }: KPICardProps) => {
const formatChange = (change: number | null, unit: string = ""): { text: string; isPositive: boolean | null; hasData: boolean } => {
if (change === null || change === undefined) {
return { text: "No historical data", isPositive: null, hasData: false };
}
const isPositive = change > 0;
const sign = isPositive ? "+" : "";
const formattedValue = Math.abs(change).toFixed(1);
return {
text: `${sign}${formattedValue}${unit ? ` ${unit}` : ""}`,
isPositive,
hasData: true
};
};
export const KPICard = memo(({
title,
value,
icon: Icon,
trend,
trendColor = "default",
change = null,
changeLabel,
changeUnit = ""
}: KPICardProps) => {
const changeInfo = formatChange(change, changeUnit);
// Show change section only if changeLabel is explicitly provided (not null/undefined)
// This allows cards to opt-out of showing historical data
const showChange = changeLabel !== undefined && changeLabel !== null;
return (
<Card className="p-4 hover:shadow-lg transition-shadow">
<div className="flex items-center gap-3 mb-2">
@ -35,8 +69,42 @@ export const KPICard = memo(({ title, value, icon: Icon, trend, trendColor = "de
</div>
<p className="text-2xl font-bold text-foreground mb-1">{value}</p>
{trend && <p className={`text-xs ${getTrendColorClass(trendColor)}`}>{trend}</p>}
{showChange && (
<div className="flex items-center gap-1 mt-2">
{changeInfo.hasData && changeInfo.isPositive !== null && (
changeInfo.isPositive ? (
<TrendingUp className="h-3 w-3 text-green-600" />
) : (
<TrendingDown className="h-3 w-3 text-red-600" />
)
)}
<p className={`text-xs ${
!changeInfo.hasData
? "text-muted-foreground italic"
: changeInfo.isPositive === null
? "text-muted-foreground"
: changeInfo.isPositive
? "text-green-600"
: "text-red-600"
}`}>
{changeInfo.hasData ? `${changeInfo.text} ${changeLabel}` : changeInfo.text}
</p>
</div>
)}
</Card>
);
}, (prevProps, nextProps) => {
// Custom comparison to prevent unnecessary re-renders
return (
prevProps.title === nextProps.title &&
prevProps.value === nextProps.value &&
prevProps.trend === nextProps.trend &&
prevProps.trendColor === nextProps.trendColor &&
prevProps.icon === nextProps.icon &&
prevProps.change === nextProps.change &&
prevProps.changeLabel === nextProps.changeLabel &&
prevProps.changeUnit === nextProps.changeUnit
);
});
KPICard.displayName = 'KPICard';

View File

@ -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<HTMLInputElement>(null);
const wasFocusedRef = useRef(false);
const cursorPositionRef = useRef<number | null>(null);
// Data for dropdowns
const [availableStates, setAvailableStates] = useState<string[]>([]);
@ -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<HTMLInputElement>) => {
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<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
// 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 */}
<div className="w-full relative">
<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, credit score, or ask a question..."
value={searchQuery}
onChange={(e) => handleSearchChange(e.target.value)}
onChange={handleSearchChange}
onFocus={handleFocus}
onBlur={handleBlur}
className="w-full pr-10"
disabled={isLoading}
autoComplete="off"
spellCheck="false"
/>
{isLoading && (
<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>
</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;
});

View File

@ -36,6 +36,13 @@ export const CreditScoreTrendChart = memo(({ creditScore, data, compact = false
</ResponsiveContainer>
</Card>
);
}, (prevProps, nextProps) => {
// Custom comparison to prevent unnecessary re-renders
return (
prevProps.creditScore === nextProps.creditScore &&
prevProps.compact === nextProps.compact &&
JSON.stringify(prevProps.data) === JSON.stringify(nextProps.data)
);
});
CreditScoreTrendChart.displayName = 'CreditScoreTrendChart';

View File

@ -38,6 +38,14 @@ export const SalesPurchaseChart = memo(({
</ResponsiveContainer>
</Card>
);
}, (prevProps, nextProps) => {
// Custom comparison to prevent unnecessary re-renders
return (
prevProps.totalSales6M === nextProps.totalSales6M &&
prevProps.totalPurchase6M === nextProps.totalPurchase6M &&
prevProps.compact === nextProps.compact &&
JSON.stringify(prevProps.data) === JSON.stringify(nextProps.data)
);
});
SalesPurchaseChart.displayName = 'SalesPurchaseChart';

View File

@ -64,6 +64,9 @@ export const StockAgeChart = memo(({ data }: { data?: any[] }) => {
</div>
</Card>
);
}, (prevProps, nextProps) => {
// Custom comparison to prevent unnecessary re-renders
return JSON.stringify(prevProps.data) === JSON.stringify(nextProps.data);
});
StockAgeChart.displayName = 'StockAgeChart';

View File

@ -1,4 +1,4 @@
import { memo, useCallback, useMemo } from 'react';
import { memo, useCallback, useMemo, useState, useEffect } from 'react';
import { Button } from '@/components/ui/button';
interface PaginationProps {
@ -14,8 +14,23 @@ export const Pagination = memo(({
onPageChange,
maxVisiblePages = 10
}: PaginationProps) => {
const isMobile = typeof window !== 'undefined' ? window.innerWidth < 640 : false;
const effectiveMaxPages = isMobile ? 3 : maxVisiblePages;
// Use state and effect to track window width instead of calculating on every render
const [isMobile, setIsMobile] = useState(() =>
typeof window !== 'undefined' ? window.innerWidth < 640 : false
);
useEffect(() => {
if (typeof window === 'undefined') return;
const handleResize = () => {
setIsMobile(window.innerWidth < 640);
};
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
const effectiveMaxPages = useMemo(() => isMobile ? 3 : maxVisiblePages, [isMobile, maxVisiblePages]);
const handlePrevious = useCallback(() => {
onPageChange(Math.max(1, currentPage - 1));

View File

@ -1,15 +1,80 @@
import { memo, useState } from 'react';
import { memo, useState, useEffect } from 'react';
import { MessageSquare, Phone, CheckCircle, ChevronDown, ChevronUp } from 'lucide-react';
import { Card } from '@/components/ui/card';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
import { useAuth } from '@/hooks/useAuth';
import { useParams } from 'react-router-dom';
import { dealerService } from '@/api/services/dealer.service';
import { LoadingSpinner } from '@/components/common/LoadingSpinner';
interface ActivityTimelineData {
lastSMS: {
date: string;
customer: string;
mobile_number?: string;
message?: string;
sms_type?: string;
} | null;
lastCall: {
date: string;
customer: string;
dealer_id?: string | number;
} | null;
lastAcknowledgment: {
date: string;
customer: string;
dealer_id?: string | number;
txn_date?: string;
} | null;
}
export const ActivityTimeline = memo(() => {
const [isOpen, setIsOpen] = useState(false);
const [activityData, setActivityData] = useState<ActivityTimelineData | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const { userRole } = useAuth();
const { id: dealerId } = useParams<{ id: string }>();
useEffect(() => {
const fetchActivityTimeline = async () => {
if (!dealerId || userRole !== 'helpdesk') return;
setLoading(true);
setError(null);
try {
const response = await dealerService.getActivityTimeline(dealerId);
if (response.success && response.data) {
setActivityData(response.data);
}
} catch (err: any) {
console.error('Error fetching activity timeline:', err);
setError(err.message || 'Failed to load activity timeline');
} finally {
setLoading(false);
}
};
if (isOpen && dealerId) {
fetchActivityTimeline();
}
}, [isOpen, dealerId, userRole]);
if (userRole !== 'helpdesk') return null;
const formatDate = (dateString: string | null | undefined) => {
if (!dateString) return 'N/A';
try {
return new Date(dateString).toLocaleDateString('en-GB', {
day: '2-digit',
month: '2-digit',
year: 'numeric'
});
} catch {
return 'N/A';
}
};
return (
<Collapsible open={isOpen} onOpenChange={setIsOpen} className="mb-6">
<Card className="p-4 sm:p-6">
@ -18,38 +83,70 @@ export const ActivityTimeline = memo(() => {
{isOpen ? <ChevronUp className="h-5 w-5" /> : <ChevronDown className="h-5 w-5" />}
</CollapsibleTrigger>
<CollapsibleContent className="mt-4">
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="flex items-start gap-3">
<MessageSquare className="h-5 w-5 text-primary mt-1" />
<div>
<p className="font-medium text-foreground">Last SMS</p>
<p className="text-sm text-muted-foreground">
Date: {new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toLocaleDateString()}
</p>
<p className="text-sm text-muted-foreground">Customer: Rahul Sharma</p>
{loading ? (
<div className="flex justify-center items-center py-8">
<LoadingSpinner />
</div>
) : error ? (
<div className="text-sm text-destructive py-4">{error}</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="flex items-start gap-3">
<MessageSquare className="h-5 w-5 text-primary mt-1" />
<div>
<p className="font-medium text-foreground">Last SMS</p>
{activityData?.lastSMS ? (
<>
<p className="text-sm text-muted-foreground">
Date: {formatDate(activityData.lastSMS.date)}
</p>
<p className="text-sm text-muted-foreground">
Customer: {activityData.lastSMS.customer}
</p>
</>
) : (
<p className="text-sm text-muted-foreground">No SMS data available</p>
)}
</div>
</div>
<div className="flex items-start gap-3">
<Phone className="h-5 w-5 text-primary mt-1" />
<div>
<p className="font-medium text-foreground">Last Call</p>
{activityData?.lastCall ? (
<>
<p className="text-sm text-muted-foreground">
Date: {formatDate(activityData.lastCall.date)}
</p>
<p className="text-sm text-muted-foreground">
Customer: {activityData.lastCall.customer}
</p>
</>
) : (
<p className="text-sm text-muted-foreground">No call data available</p>
)}
</div>
</div>
<div className="flex items-start gap-3">
<CheckCircle className="h-5 w-5 text-primary mt-1" />
<div>
<p className="font-medium text-foreground">Last Acknowledgment</p>
{activityData?.lastAcknowledgment ? (
<>
<p className="text-sm text-muted-foreground">
Date: {formatDate(activityData.lastAcknowledgment.date)}
</p>
<p className="text-sm text-muted-foreground">
Customer: {activityData.lastAcknowledgment.customer}
</p>
</>
) : (
<p className="text-sm text-muted-foreground">No acknowledgment data available</p>
)}
</div>
</div>
</div>
<div className="flex items-start gap-3">
<Phone className="h-5 w-5 text-primary mt-1" />
<div>
<p className="font-medium text-foreground">Last Call</p>
<p className="text-sm text-muted-foreground">
Date: {new Date(Date.now() - 5 * 24 * 60 * 60 * 1000).toLocaleDateString()}
</p>
<p className="text-sm text-muted-foreground">Customer: Priya Patel</p>
</div>
</div>
<div className="flex items-start gap-3">
<CheckCircle className="h-5 w-5 text-primary mt-1" />
<div>
<p className="font-medium text-foreground">Last Acknowledgment</p>
<p className="text-sm text-muted-foreground">
Date: {new Date(Date.now() - 3 * 24 * 60 * 60 * 1000).toLocaleDateString()}
</p>
<p className="text-sm text-muted-foreground">Customer: Amit Kumar</p>
</div>
</div>
</div>
)}
</CollapsibleContent>
</Card>
</Collapsible>

View File

@ -1,6 +1,7 @@
import { memo } from 'react';
import { ClipboardList } from 'lucide-react';
import { Card } from '@/components/ui/card';
import { getCreditScoreColor } from '@/lib/mockData';
import type { ScoreParameter } from '@/lib/mockData';
interface CreditScoreBreakdownProps {
@ -8,6 +9,18 @@ interface CreditScoreBreakdownProps {
compact?: boolean;
}
const colorClasses = {
success: "bg-success",
warning: "bg-warning",
danger: "bg-danger",
} as const;
const textColorClasses = {
success: "text-success",
warning: "text-warning",
danger: "text-danger",
} as const;
export const CreditScoreBreakdown = memo(({
scoreBreakdown,
compact = false
@ -17,23 +30,26 @@ export const CreditScoreBreakdown = memo(({
<Card className="p-4">
<h3 className="text-lg font-semibold mb-2 text-foreground">Credit Score Breakdown</h3>
<div className="space-y-2">
{scoreBreakdown.map((param, idx) => (
<div key={idx} className="space-y-1">
<div className="flex justify-between items-center">
<span className="text-xs font-medium text-foreground">{param.parameter}</span>
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">{param.weight}%</span>
<span className="text-xs font-semibold text-primary">{param.dealerScore}</span>
{scoreBreakdown.map((param, idx) => {
const color = getCreditScoreColor(param.dealerScore);
return (
<div key={idx} className="space-y-1">
<div className="flex justify-between items-center">
<span className="text-xs font-medium text-foreground">{param.parameter}</span>
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">{param.weight}%</span>
<span className={`text-xs font-semibold ${textColorClasses[color]}`}>{param.dealerScore}</span>
</div>
</div>
<div className="h-1.5 bg-muted rounded-full overflow-hidden">
<div
className={`h-full ${colorClasses[color]} transition-all`}
style={{ width: `${(param.dealerScore / 1000) * 100}%` }}
/>
</div>
</div>
<div className="h-1.5 bg-muted rounded-full overflow-hidden">
<div
className="h-full bg-primary transition-all"
style={{ width: `${(param.dealerScore / 1000) * 100}%` }}
/>
</div>
</div>
))}
);
})}
</div>
</Card>
);
@ -46,24 +62,27 @@ export const CreditScoreBreakdown = memo(({
<h2 className="text-xl font-semibold text-foreground">Credit Score Breakdown</h2>
</div>
<div className="space-y-4">
{scoreBreakdown.map((param, idx) => (
<div key={idx} className="space-y-2">
<div className="flex justify-between items-center">
<span className="text-sm font-medium text-foreground">{param.parameter}</span>
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">{param.weight}%</span>
<span className="text-sm font-semibold text-primary">{param.dealerScore}</span>
{scoreBreakdown.map((param, idx) => {
const color = getCreditScoreColor(param.dealerScore);
return (
<div key={idx} className="space-y-2">
<div className="flex justify-between items-center">
<span className="text-sm font-medium text-foreground">{param.parameter}</span>
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">{param.weight}%</span>
<span className={`text-sm font-semibold ${textColorClasses[color]}`}>{param.dealerScore}</span>
</div>
</div>
<div className="h-2 bg-muted rounded-full overflow-hidden">
<div
className={`h-full ${colorClasses[color]} transition-all`}
style={{ width: `${(param.dealerScore / 1000) * 100}%` }}
/>
</div>
<p className="text-xs text-muted-foreground">{param.remarks}</p>
</div>
<div className="h-2 bg-muted rounded-full overflow-hidden">
<div
className="h-full bg-primary transition-all"
style={{ width: `${(param.dealerScore / 1000) * 100}%` }}
/>
</div>
<p className="text-xs text-muted-foreground">{param.remarks}</p>
</div>
))}
);
})}
</div>
</Card>
);

View File

@ -1,4 +1,4 @@
import { memo } from 'react';
import { memo, useMemo } from 'react';
import { ArrowLeft } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
@ -24,14 +24,15 @@ export const DealerProfileHeader = memo(({
onViewScoreCard,
}: DealerProfileHeaderProps) => {
const { userRole } = useAuth();
const lastUpdated = new Date().toLocaleString('en-GB', {
// Memoize lastUpdated to prevent recalculation on every render
const lastUpdated = useMemo(() => new Date().toLocaleString('en-GB', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
});
}), []);
return (
<header className="bg-[#E8F5E9] border-b border-border py-4 px-4 sm:px-8">
@ -43,6 +44,37 @@ export const DealerProfileHeader = memo(({
</Button>
<div>
<h1 className="text-xl sm:text-2xl font-bold text-foreground m-0 leading-tight">{dealer.dealerName}</h1>
<div className="mt-1 space-y-1">
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-xs sm:text-sm text-muted-foreground">
<span><strong>MFMS ID:</strong> {dealer.mfmsId}</span>
<span></span>
<span>
{dealer.district}
{dealer.city && dealer.city !== dealer.district ? `, ${dealer.city}` : ''}
{dealer.state ? `, ${dealer.state}` : ''}
</span>
<span></span>
<span>{dealer.dealerType}</span>
<span></span>
<span>
<Badge variant={isActive ? "default" : "secondary"} className={isActive ? "bg-[#16A34A]" : ""}>
{isActive ? "Active" : "Inactive"}
</Badge>
</span>
</div>
{showScoreCard && (
<div className="mt-1">
<Button
variant="outline"
size="sm"
onClick={onViewScoreCard}
className="bg-[#16A34A] text-white hover:bg-[#15803D]"
>
View Score Card
</Button>
</div>
)}
</div>
</div>
</div>
<div className="text-left sm:text-right ml-11 sm:ml-0">
@ -65,36 +97,19 @@ export const DealerProfileHeader = memo(({
</div>
</div>
</div>
<div className="ml-11 sm:ml-14 mt-2 sm:mt-1 space-y-1">
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-xs sm:text-sm text-muted-foreground">
<span><strong>MFMS ID:</strong> {dealer.mfmsId}</span>
<span></span>
<span>{dealer.district}, {dealer.city}, {dealer.state}</span>
<span></span>
<span>{dealer.dealerType}</span>
<span></span>
<span>
<Badge variant={isActive ? "default" : "secondary"} className={isActive ? "bg-[#16A34A]" : ""}>
{isActive ? "Active" : "Inactive"}
</Badge>
</span>
</div>
</div>
{showScoreCard && (
<div className="ml-11 sm:ml-14 mt-3 sm:mt-1">
<Button
variant="outline"
size="sm"
onClick={onViewScoreCard}
className="bg-[#16A34A] text-white hover:bg-[#15803D]"
>
View Score Card
</Button>
</div>
)}
</div>
</header>
);
}, (prevProps, nextProps) => {
// Custom comparison function to prevent unnecessary re-renders
return (
prevProps.dealer.id === nextProps.dealer.id &&
prevProps.dealer.creditScore === nextProps.dealer.creditScore &&
prevProps.creditColor === nextProps.creditColor &&
prevProps.isEligible === nextProps.isEligible &&
prevProps.isActive === nextProps.isActive &&
prevProps.showScoreCard === nextProps.showScoreCard
);
});
DealerProfileHeader.displayName = 'DealerProfileHeader';

View File

@ -1,4 +1,4 @@
import { memo } from 'react';
import { memo, useMemo, useCallback } from 'react';
import { User } from 'lucide-react';
import { Card } from '@/components/ui/card';
import type { Dealer } from '@/lib/mockData';
@ -9,7 +9,8 @@ interface DealerSnapshotProps {
}
export const DealerSnapshot = memo(({ dealer, title = "Dealer Snapshot" }: DealerSnapshotProps) => {
const snapshotItems = [
// Memoize snapshotItems to prevent recreation on every render
const snapshotItems = useMemo(() => [
{ label: "Type", value: dealer.dealerType },
{ label: "Total Companies Associated", value: dealer.noOfCompanies },
{ label: "Active Products", value: dealer.noOfProducts },
@ -20,7 +21,7 @@ export const DealerSnapshot = memo(({ dealer, title = "Dealer Snapshot" }: Deale
{ label: "Avg. Stock Age", value: `${dealer.stockAge} Days` },
{ label: "Aged Stock (>90 Days)", value: `${dealer.agedStock} MT` },
{ label: "Current Stock Quantity", value: `${dealer.currentStock} MT` },
];
], [dealer]);
return (
<Card className="p-4 sm:p-6">
@ -69,44 +70,40 @@ interface CompareBusinessSnapshotProps {
data: ManufacturerData;
}
// Helper function outside component to prevent recreation
const getValue = (value: number | { value: number; unit: string } | undefined, defaultVal: number = 0): number => {
if (value === undefined) return defaultVal;
if (typeof value === 'number') return value;
return value.value || defaultVal;
};
export const CompareBusinessSnapshot = memo(({ data }: CompareBusinessSnapshotProps) => {
// Helper function to extract value from API response (handles both formats)
const getValue = (value: number | { value: number; unit: string } | undefined, defaultVal: number = 0): number => {
if (value === undefined) return defaultVal;
if (typeof value === 'number') return value;
return value.value || defaultVal;
};
// Helper function to get unit from API response
const getUnit = (value: number | { value: number; unit: string } | undefined, defaultUnit: string = ''): string => {
if (value === undefined || typeof value === 'number') return defaultUnit;
return value.unit || defaultUnit;
};
// Extract values from API response format or fallback to direct values
const type = data.type || 'N/A';
const totalCompanies = data.total_companies_associated ?? data.totalCompanies ?? 0;
const activeProducts = data.active_products ?? data.activeProducts ?? 0;
const totalSales = getValue(data.total_sales_6m_rolling ?? data.totalSales, 0);
const totalPurchase = getValue(data.total_purchase_6m_rolling ?? data.totalPurchase, 0);
const avgLiquidityCycle = getValue(data.avg_liquidity_cycle_3m_weighted ?? data.avgLiquidityCycle, 0);
const avgAcknowledgmentCycle = getValue(data.avg_acknowledgment_cycle_3m_weighted ?? data.avgAcknowledgmentCycle, 0);
const avgStockAge = getValue(data.avg_stock_age ?? data.avgStockAge, 0);
const agedStock = getValue(data.aged_stock_over_90_days ?? data.agedStock, 0);
const currentStock = getValue(data.current_stock_quantity ?? data.currentStock, 0);
const snapshotItems = useMemo(() => {
const type = data.type || 'N/A';
const totalCompanies = data.total_companies_associated ?? data.totalCompanies ?? 0;
const activeProducts = data.active_products ?? data.activeProducts ?? 0;
const totalSales = getValue(data.total_sales_6m_rolling ?? data.totalSales, 0);
const totalPurchase = getValue(data.total_purchase_6m_rolling ?? data.totalPurchase, 0);
const avgLiquidityCycle = getValue(data.avg_liquidity_cycle_3m_weighted ?? data.avgLiquidityCycle, 0);
const avgAcknowledgmentCycle = getValue(data.avg_acknowledgment_cycle_3m_weighted ?? data.avgAcknowledgmentCycle, 0);
const avgStockAge = getValue(data.avg_stock_age ?? data.avgStockAge, 0);
const agedStock = getValue(data.aged_stock_over_90_days ?? data.agedStock, 0);
const currentStock = getValue(data.current_stock_quantity ?? data.currentStock, 0);
const snapshotItems = [
{ label: "Type", value: type },
{ label: "Total Companies Associated", value: totalCompanies },
{ label: "Active Products", value: activeProducts },
{ label: "Total Sales (6M Rolling)", value: `${totalSales} MT` },
{ label: "Total Purchase (6M Rolling)", value: `${totalPurchase} MT` },
{ label: "Avg. Liquidity Cycle (3M Weighted)", value: `${avgLiquidityCycle > 0 ? avgLiquidityCycle.toFixed(1) : 0} Days` },
{ label: "Avg. Acknowledgment Cycle (3M Weighted)", value: `${avgAcknowledgmentCycle > 0 ? avgAcknowledgmentCycle.toFixed(1) : 0} Days` },
{ label: "Avg. Stock Age", value: `${avgStockAge} Days` },
{ label: "Aged Stock (>90 Days)", value: `${agedStock} MT` },
{ label: "Current Stock Quantity", value: `${currentStock} MT` },
];
return [
{ label: "Type", value: type },
{ label: "Total Companies Associated", value: totalCompanies },
{ label: "Active Products", value: activeProducts },
{ label: "Total Sales (6M Rolling)", value: `${totalSales} MT` },
{ label: "Total Purchase (6M Rolling)", value: `${totalPurchase} MT` },
{ label: "Avg. Liquidity Cycle (3M Weighted)", value: `${avgLiquidityCycle > 0 ? avgLiquidityCycle.toFixed(1) : 0} Days` },
{ label: "Avg. Acknowledgment Cycle (3M Weighted)", value: `${avgAcknowledgmentCycle > 0 ? avgAcknowledgmentCycle.toFixed(1) : 0} Days` },
{ label: "Avg. Stock Age", value: `${avgStockAge} Days` },
{ label: "Aged Stock (>90 Days)", value: `${agedStock} MT` },
{ label: "Current Stock Quantity", value: `${currentStock} MT` },
];
}, [data]);
return (
<Card className="p-4 sm:p-6">

View File

@ -18,10 +18,10 @@ const TableHeader = memo(() => (
<th className="px-4 py-3 text-left text-sm font-medium text-foreground">Mobile Number</th>
<th className="px-4 py-3 text-left text-sm font-medium text-foreground">Products</th>
<th className="px-4 py-3 text-left text-sm font-medium text-foreground">Companies</th>
<th className="px-4 py-3 text-left text-sm font-medium text-foreground">Sales Rating</th>
<th className="px-4 py-3 text-left text-sm font-medium text-foreground">Buy Rating</th>
<th className="px-4 py-3 text-left text-sm font-medium text-foreground">Liquidity Cycle</th>
<th className="px-4 py-3 text-left text-sm font-medium text-foreground">Ack. Cycle</th>
<th className="px-4 py-3 text-left text-sm font-medium text-foreground">Total Sales (MT)</th>
<th className="px-4 py-3 text-left text-sm font-medium text-foreground">Total Buy (MT)</th>
<th className="px-4 py-3 text-left text-sm font-medium text-foreground">LIQ Cycle</th>
<th className="px-4 py-3 text-left text-sm font-medium text-foreground">ACK Cycle</th>
<th className="px-4 py-3 text-left text-sm font-medium text-foreground">Current Stock</th>
<th className="px-4 py-3 text-left text-sm font-medium text-foreground">Aged Stock</th>
<th className="px-4 py-3 text-left text-sm font-medium text-foreground min-w-[200px]">Credit Score</th>

View File

@ -32,21 +32,49 @@ export const DealerTableRow = memo(({
<td className="px-4 py-3 text-sm">{dealer.mobile || "N/A"}</td>
<td className="px-4 py-3 text-sm text-center">{dealer.noOfProducts}</td>
<td className="px-4 py-3 text-sm text-center">{dealer.noOfCompanies}</td>
<td className="px-4 py-3 text-sm text-center">{dealer.salesRating}</td>
<td className="px-4 py-3 text-sm text-center">{dealer.buyRating}</td>
<td className="px-4 py-3 text-sm text-center">
{dealer.totalSales6M > 0 ? parseFloat(Number(dealer.totalSales6M).toFixed(2)).toString() : '0'} MT
</td>
<td className="px-4 py-3 text-sm text-center">
{dealer.totalPurchase6M > 0 ? parseFloat(Number(dealer.totalPurchase6M).toFixed(2)).toString() : '0'} MT
</td>
<td className="px-4 py-3 text-sm text-center">
{dealer.avgLiquidityCycle > 0 ? `${dealer.avgLiquidityCycle.toFixed(1)} Days` : '0 Days'}
</td>
<td className="px-4 py-3 text-sm text-center">
{dealer.avgAcknowledgmentCycle > 0 ? `${dealer.avgAcknowledgmentCycle.toFixed(1)} Days` : '0 Days'}
</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">{dealer.agedStock} MT</td>
<td className="px-4 py-3 text-sm text-center">
{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">
<CreditScoreBar score={dealer.creditScore} />
</td>
</tr>
);
}, (prevProps, nextProps) => {
// Custom comparison to prevent unnecessary re-renders
return (
prevProps.dealer.id === nextProps.dealer.id &&
prevProps.dealer.creditScore === nextProps.dealer.creditScore &&
prevProps.dealer.state === nextProps.dealer.state &&
prevProps.dealer.district === nextProps.dealer.district &&
prevProps.dealer.dealerName === nextProps.dealer.dealerName &&
prevProps.dealer.mfmsId === nextProps.dealer.mfmsId &&
prevProps.dealer.mobile === nextProps.dealer.mobile &&
prevProps.dealer.noOfProducts === nextProps.dealer.noOfProducts &&
prevProps.dealer.noOfCompanies === nextProps.dealer.noOfCompanies &&
prevProps.dealer.totalSales6M === nextProps.dealer.totalSales6M &&
prevProps.dealer.totalPurchase6M === nextProps.dealer.totalPurchase6M &&
prevProps.dealer.avgLiquidityCycle === nextProps.dealer.avgLiquidityCycle &&
prevProps.dealer.avgAcknowledgmentCycle === nextProps.dealer.avgAcknowledgmentCycle &&
prevProps.dealer.currentStock === nextProps.dealer.currentStock &&
prevProps.dealer.agedStock === nextProps.dealer.agedStock &&
prevProps.isHovered === nextProps.isHovered
);
});
DealerTableRow.displayName = 'DealerTableRow';

View File

@ -1,4 +1,4 @@
import { createContext, useContext, useState, useEffect, useCallback } from 'react';
import { createContext, useContext, useState, useEffect, useCallback, useMemo } from 'react';
import type { ReactNode } from 'react';
import { useNavigate } from 'react-router-dom';
import { authService, type AppUser, type UserRole } from '@/api';
@ -157,8 +157,14 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => {
navigate('/login');
};
// Memoize context value to prevent unnecessary re-renders
const contextValue = useMemo(
() => ({ user, session, userRole, loading, signIn, signOut }),
[user, session, userRole, loading, signIn, signOut]
);
return (
<AuthContext.Provider value={{ user, session, userRole, loading, signIn, signOut }}>
<AuthContext.Provider value={contextValue}>
{children}
</AuthContext.Provider>
);

View File

@ -6,11 +6,10 @@ import { useDebounce } from './useDebounce';
interface DealerOverviewMetrics {
totalDealers: number;
avgCreditScore: number;
avgCreditScoreChange: number | null;
highRiskPercentage: string;
avgLiquidityCycle: number;
// Add trend details if available in API response, for now we map what we can
// The UI expects detailed trends which might not be in the simple overview stats
// We'll see what the API returns.
avgLiquidityCycleChange: number | null;
}
interface UseDealerOverviewReturn {
@ -111,8 +110,10 @@ export function useDealerOverview(filters: FilterState, searchQuery: string): Us
setMetrics({
totalDealers: data.totalDealers || data.total_dealers || 0,
avgCreditScore: data.avgCreditScore || data.avg_credit_score || 0,
avgCreditScoreChange: data.avgCreditScoreChange !== undefined ? data.avgCreditScoreChange : (data.avg_credit_score_change !== undefined ? data.avg_credit_score_change : null),
highRiskPercentage: data.highRiskPercentage || data.high_risk_dealers_percentage || "0.0",
avgLiquidityCycle: data.avgLiquidityCycle || data.avg_liquidity_cycle_days || data.avg_liquidity_cycle || 0
avgLiquidityCycle: data.avgLiquidityCycle || data.avg_liquidity_cycle_days || data.avg_liquidity_cycle || 0,
avgLiquidityCycleChange: data.avgLiquidityCycleChange !== undefined ? data.avgLiquidityCycleChange : (data.avg_liquidity_cycle_change !== undefined ? data.avg_liquidity_cycle_change : null)
});
} else {
setError(response.message || 'Failed to fetch overview data');

View File

@ -23,6 +23,7 @@ export interface Dealer {
mobile?: string;
aadhaar?: string;
dealerLicense?: string;
isActive?: boolean;
}
// Get all Indian states using country-state-city

View File

@ -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,72 @@ 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,
avgCreditScoreChange: null,
highRiskPercentage: "0.0",
avgLiquidityCycle: 0
};
avgLiquidityCycle: 0,
avgLiquidityCycleChange: null
}, [metrics]);
// Generate dynamic labels based on active filters
const getDynamicLabels = useMemo(() => {
const hasFilters = filters.states.length > 0 ||
filters.districts.length > 0 ||
filters.dealerType !== 'all' ||
filters.minCreditScore > 0 ||
filters.maxCreditScore < 1000 ||
searchQuery.trim().length > 0;
if (!hasFilters) {
return {
totalDealersTrend: "Active in system",
highRiskTrend: "Score below 500"
};
}
// Build filter description
const filterParts: string[] = [];
if (filters.states.length > 0) {
if (filters.states.length === 1) {
filterParts.push(filters.states[0]);
} else {
filterParts.push(`${filters.states.length} states`);
}
}
if (filters.districts.length > 0) {
if (filters.districts.length === 1) {
filterParts.push(filters.districts[0]);
} else {
filterParts.push(`${filters.districts.length} districts`);
}
}
if (filters.dealerType !== 'all') {
filterParts.push(filters.dealerType);
}
if (searchQuery.trim().length > 0) {
filterParts.push("search results");
}
const filterDescription = filterParts.length > 0
? filterParts.slice(0, 2).join(", ") + (filterParts.length > 2 ? "..." : "")
: "selected filters";
return {
totalDealersTrend: `Active in ${filterDescription}`,
highRiskTrend: "Score below 500"
};
}, [filters, searchQuery]);
// Redirect to login if not authenticated
useEffect(() => {
@ -130,6 +185,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 +212,14 @@ const Dashboard = () => {
</div>
{/* 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">
{overviewLoading || loading ? (
{overviewLoading ? (
// Show loading skeleton for all cards
<>
{[1, 2, 3, 4].map((i) => (
@ -165,28 +236,30 @@ const Dashboard = () => {
title="Total Dealers"
value={displayKPIs.totalDealers?.toLocaleString() || "0"}
icon={Users}
trend="Active in system"
trend={getDynamicLabels.totalDealersTrend}
/>
<KPICard
title="Avg Credit Score"
value={displayKPIs.avgCreditScore}
value={typeof displayKPIs.avgCreditScore === 'number' ? displayKPIs.avgCreditScore.toFixed(1) : displayKPIs.avgCreditScore}
icon={TrendingUp}
trend="↑ 2.3% from last month"
trendColor="success"
change={displayKPIs.avgCreditScoreChange}
changeLabel="vs last month"
changeUnit="%"
/>
<KPICard
title="High-Risk Dealers"
value={`${displayKPIs.highRiskPercentage}%`}
icon={AlertTriangle}
trend="Score below 500"
trend={getDynamicLabels.highRiskTrend}
trendColor="danger"
/>
<KPICard
title="Avg Liquidity Cycle"
value={`${displayKPIs.avgLiquidityCycle} Days`}
value={`${typeof displayKPIs.avgLiquidityCycle === 'number' ? displayKPIs.avgLiquidityCycle.toFixed(2) : displayKPIs.avgLiquidityCycle} Days`}
icon={Clock}
trend="↓ 1.8 days improved"
trendColor="success"
change={displayKPIs.avgLiquidityCycleChange}
changeLabel="vs last month"
changeUnit="days"
/>
</>
)}
@ -195,8 +268,8 @@ const Dashboard = () => {
{/* Search & Filters */}
<div className="mb-6">
<SearchFilters
onSearch={setSearchQuery}
onFilter={setFilters}
onSearch={handleSearch}
onFilter={handleFilter}
onDownload={handleDownload}
isLoading={listLoading}
/>
@ -226,10 +299,10 @@ const Dashboard = () => {
<th className="px-4 py-3 text-left text-sm font-medium text-foreground">Mobile Number</th>
<th className="px-4 py-3 text-left text-sm font-medium text-foreground">Products</th>
<th className="px-4 py-3 text-left text-sm font-medium text-foreground">Companies</th>
<th className="px-4 py-3 text-left text-sm font-medium text-foreground">Sales Rating</th>
<th className="px-4 py-3 text-left text-sm font-medium text-foreground">Buy Rating</th>
<th className="px-4 py-3 text-left text-sm font-medium text-foreground">Liquidity Cycle</th>
<th className="px-4 py-3 text-left text-sm font-medium text-foreground">Ack. Cycle</th>
<th className="px-4 py-3 text-left text-sm font-medium text-foreground">Total Sales (MT)</th>
<th className="px-4 py-3 text-left text-sm font-medium text-foreground">Total Buy (MT)</th>
<th className="px-4 py-3 text-left text-sm font-medium text-foreground">LIQ Cycle</th>
<th className="px-4 py-3 text-left text-sm font-medium text-foreground">ACK Cycle</th>
<th className="px-4 py-3 text-left text-sm font-medium text-foreground">Current Stock</th>
<th className="px-4 py-3 text-left text-sm font-medium text-foreground">Aged Stock</th>
<th className="px-4 py-3 text-left text-sm font-medium text-foreground min-w-[200px]">Credit Score</th>

View File

@ -1,5 +1,5 @@
import { useParams, useNavigate } from "react-router-dom";
import { useEffect, useMemo, useCallback } from "react";
import { useEffect, useMemo, useCallback, memo } from "react";
import { dealers, getScoreBreakdown, getCreditScoreColor, type Dealer, type ScoreParameter } from "@/lib/mockData";
import { dealerService } from "@/api/services/dealer.service";
import { useState } from "react";
@ -85,7 +85,8 @@ const DealerProfile = () => {
stockAge: d.stock_age || 0,
mobile: d.mobile_number || d.mobile,
aadhaar: d.aadhaar,
dealerLicense: d.dealer_license || d.license_number
dealerLicense: d.dealer_license || d.license_number,
isActive: d.is_active !== undefined ? d.is_active : true
};
}
@ -184,8 +185,7 @@ const DealerProfile = () => {
console.log('Setting compareBusinessData from API');
setCompareBusinessData(compareRes.data);
} else {
console.log('Compare API failed or no data, will use manufacturerData fallback');
console.log('manufacturerData:', manufacturerData);
console.log('Compare API failed or no data, will use fallback data from dealer snapshot');
}
if (mappedDealer) {
@ -224,11 +224,11 @@ const DealerProfile = () => {
[dealer]
);
// Use seeded random for consistent active status
// Get active status from backend data (defaults to true if not provided)
const isActive = useMemo(() => {
if (!dealer) return false;
const seed = parseInt(dealer.id.replace('DLR', ''), 10) || 1;
return Math.sin(seed) * 10000 - Math.floor(Math.sin(seed) * 10000) > 0.3;
// Use isActive from dealer data if available, otherwise default to true
return dealer.isActive !== undefined ? dealer.isActive : true;
}, [dealer]);
const isEligible = dealer ? dealer.creditScore >= 500 : false;
@ -378,4 +378,6 @@ const DealerProfile = () => {
);
};
export default DealerProfile;
// Memoize the component to prevent unnecessary re-renders
const DealerProfileMemo = memo(DealerProfile);
export default DealerProfileMemo;

View File

@ -213,6 +213,7 @@ const ScoreCard = () => {
<h3 className="text-lg sm:text-xl font-bold text-foreground mb-2">
Month-on-Month Product-wise Score Trends
</h3>
{/* TODO: Update explanatory note below to reflect only parameters currently considered in score calculation */}
<p className="text-xs sm:text-sm text-muted-foreground">
Scores are calculated based on various performance metrics. Total weightage per product per month: 1000 points
</p>
@ -276,6 +277,7 @@ const ScoreCard = () => {
{/* Card 4: Footer Note */}
<Card className="p-6">
{/* TODO: Update note below to reflect only parameters currently considered in score calculation */}
<p className="text-sm text-muted-foreground">
<strong>Note:</strong> This score card provides a detailed view of product-wise performance trends over the last 6 months. Scores are calculated based on sales velocity, purchase consistency, stock management, and payment timeliness for each product category.
</p>

View File

@ -14,24 +14,5 @@ export default defineConfig({
'@': path.resolve(__dirname, './src'),
},
},
/* server: {
proxy: {
'/api': {
target: 'http://localhost:8003 ',
changeOrigin: true,
secure: false,
configure: (proxy, _options) => {
proxy.on('error', (err, _req, _res) => {
console.log('proxy error', err);
});
proxy.on('proxyReq', (proxyReq, req, _res) => {
console.log('Sending Request to the Target:', req.method, req.url);
});
proxy.on('proxyRes', (proxyRes, req, _res) => {
console.log('Received Response from the Target:', proxyRes.statusCode, req.url);
});
},
},
},
}, */
})