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": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "tsc -b && vite build", "build": "tsc -b && vite build",
"build:docker": "vite build",
"lint": "eslint .", "lint": "eslint .",
"preview": "vite preview" "preview": "vite preview"
}, },

View File

@ -3,9 +3,29 @@ import axios from 'axios';
/** /**
* Custom Axios instance with pre-configured base URL and timeout. * 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 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({ const axiosInstance = axios.create({
baseURL: 'http://localhost:8003/api', // Direct backend URL (requires CORS on backend) baseURL: getBaseURL(),
timeout: 30000, // Default timeout (30 seconds) timeout: 30000, // Default timeout (30 seconds)
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
@ -18,7 +38,7 @@ const axiosInstance = axios.create({
*/ */
export const createExtendedTimeoutInstance = (timeout: number = 120000) => { export const createExtendedTimeoutInstance = (timeout: number = 120000) => {
return axios.create({ return axios.create({
baseURL: 'http://localhost:8003/api', baseURL: getBaseURL(),
timeout, timeout,
headers: { headers: {
'Content-Type': 'application/json', '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: (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`, 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}`, 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. * 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 { try {
const response = await axiosInstance.get<ApiResponse<any>>( const url = manufacturerSchema
API_ENDPOINTS.DEALERS.COMPARE_DEALER_BUSINESS(id) ? `${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; return response.data;
} catch (error) { } catch (error) {
throw error; throw error;
@ -292,4 +295,18 @@ export const dealerService = {
throw error; 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 { Card } from "@/components/ui/card";
import type { LucideProps } from "lucide-react"; import type { LucideProps } from "lucide-react";
import type { ForwardRefExoticComponent, RefAttributes } from "react"; import type { ForwardRefExoticComponent, RefAttributes } from "react";
import { TrendingUp, TrendingDown } from "lucide-react";
type LucideIcon = ForwardRefExoticComponent<Omit<LucideProps, "ref"> & RefAttributes<SVGSVGElement>>; type LucideIcon = ForwardRefExoticComponent<Omit<LucideProps, "ref"> & RefAttributes<SVGSVGElement>>;
@ -11,6 +12,9 @@ interface KPICardProps {
icon: LucideIcon; icon: LucideIcon;
trend?: string; trend?: string;
trendColor?: "success" | "danger" | "default"; 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 => { 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 ( return (
<Card className="p-4 hover:shadow-lg transition-shadow"> <Card className="p-4 hover:shadow-lg transition-shadow">
<div className="flex items-center gap-3 mb-2"> <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> </div>
<p className="text-2xl font-bold text-foreground mb-1">{value}</p> <p className="text-2xl font-bold text-foreground mb-1">{value}</p>
{trend && <p className={`text-xs ${getTrendColorClass(trendColor)}`}>{trend}</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> </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'; 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 { 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, 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;
});

View File

@ -36,6 +36,13 @@ export const CreditScoreTrendChart = memo(({ creditScore, data, compact = false
</ResponsiveContainer> </ResponsiveContainer>
</Card> </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'; CreditScoreTrendChart.displayName = 'CreditScoreTrendChart';

View File

@ -38,6 +38,14 @@ export const SalesPurchaseChart = memo(({
</ResponsiveContainer> </ResponsiveContainer>
</Card> </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'; SalesPurchaseChart.displayName = 'SalesPurchaseChart';

View File

@ -64,6 +64,9 @@ export const StockAgeChart = memo(({ data }: { data?: any[] }) => {
</div> </div>
</Card> </Card>
); );
}, (prevProps, nextProps) => {
// Custom comparison to prevent unnecessary re-renders
return JSON.stringify(prevProps.data) === JSON.stringify(nextProps.data);
}); });
StockAgeChart.displayName = 'StockAgeChart'; 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'; import { Button } from '@/components/ui/button';
interface PaginationProps { interface PaginationProps {
@ -14,8 +14,23 @@ export const Pagination = memo(({
onPageChange, onPageChange,
maxVisiblePages = 10 maxVisiblePages = 10
}: PaginationProps) => { }: PaginationProps) => {
const isMobile = typeof window !== 'undefined' ? window.innerWidth < 640 : false; // Use state and effect to track window width instead of calculating on every render
const effectiveMaxPages = isMobile ? 3 : maxVisiblePages; 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(() => { const handlePrevious = useCallback(() => {
onPageChange(Math.max(1, currentPage - 1)); 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 { MessageSquare, Phone, CheckCircle, ChevronDown, ChevronUp } from 'lucide-react';
import { Card } from '@/components/ui/card'; import { Card } from '@/components/ui/card';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
import { useAuth } from '@/hooks/useAuth'; 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(() => { export const ActivityTimeline = memo(() => {
const [isOpen, setIsOpen] = useState(false); 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 { 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; 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 ( return (
<Collapsible open={isOpen} onOpenChange={setIsOpen} className="mb-6"> <Collapsible open={isOpen} onOpenChange={setIsOpen} className="mb-6">
<Card className="p-4 sm:p-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" />} {isOpen ? <ChevronUp className="h-5 w-5" /> : <ChevronDown className="h-5 w-5" />}
</CollapsibleTrigger> </CollapsibleTrigger>
<CollapsibleContent className="mt-4"> <CollapsibleContent className="mt-4">
{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="grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<MessageSquare className="h-5 w-5 text-primary mt-1" /> <MessageSquare className="h-5 w-5 text-primary mt-1" />
<div> <div>
<p className="font-medium text-foreground">Last SMS</p> <p className="font-medium text-foreground">Last SMS</p>
{activityData?.lastSMS ? (
<>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Date: {new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toLocaleDateString()} Date: {formatDate(activityData.lastSMS.date)}
</p> </p>
<p className="text-sm text-muted-foreground">Customer: Rahul Sharma</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> </div>
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<Phone className="h-5 w-5 text-primary mt-1" /> <Phone className="h-5 w-5 text-primary mt-1" />
<div> <div>
<p className="font-medium text-foreground">Last Call</p> <p className="font-medium text-foreground">Last Call</p>
{activityData?.lastCall ? (
<>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Date: {new Date(Date.now() - 5 * 24 * 60 * 60 * 1000).toLocaleDateString()} Date: {formatDate(activityData.lastCall.date)}
</p> </p>
<p className="text-sm text-muted-foreground">Customer: Priya Patel</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> </div>
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<CheckCircle className="h-5 w-5 text-primary mt-1" /> <CheckCircle className="h-5 w-5 text-primary mt-1" />
<div> <div>
<p className="font-medium text-foreground">Last Acknowledgment</p> <p className="font-medium text-foreground">Last Acknowledgment</p>
{activityData?.lastAcknowledgment ? (
<>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Date: {new Date(Date.now() - 3 * 24 * 60 * 60 * 1000).toLocaleDateString()} Date: {formatDate(activityData.lastAcknowledgment.date)}
</p> </p>
<p className="text-sm text-muted-foreground">Customer: Amit Kumar</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>
</div> </div>
)}
</CollapsibleContent> </CollapsibleContent>
</Card> </Card>
</Collapsible> </Collapsible>

View File

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

View File

@ -1,4 +1,4 @@
import { memo } from 'react'; import { memo, useMemo } from 'react';
import { ArrowLeft } from 'lucide-react'; import { ArrowLeft } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
@ -24,14 +24,15 @@ export const DealerProfileHeader = memo(({
onViewScoreCard, onViewScoreCard,
}: DealerProfileHeaderProps) => { }: DealerProfileHeaderProps) => {
const { userRole } = useAuth(); 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', day: '2-digit',
month: '2-digit', month: '2-digit',
year: 'numeric', year: 'numeric',
hour: '2-digit', hour: '2-digit',
minute: '2-digit', minute: '2-digit',
second: '2-digit', second: '2-digit',
}); }), []);
return ( return (
<header className="bg-[#E8F5E9] border-b border-border py-4 px-4 sm:px-8"> <header className="bg-[#E8F5E9] border-b border-border py-4 px-4 sm:px-8">
@ -43,6 +44,37 @@ export const DealerProfileHeader = memo(({
</Button> </Button>
<div> <div>
<h1 className="text-xl sm:text-2xl font-bold text-foreground m-0 leading-tight">{dealer.dealerName}</h1> <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> </div>
<div className="text-left sm:text-right ml-11 sm:ml-0"> <div className="text-left sm:text-right ml-11 sm:ml-0">
@ -65,36 +97,19 @@ export const DealerProfileHeader = memo(({
</div> </div>
</div> </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> </div>
</header> </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'; 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 { User } from 'lucide-react';
import { Card } from '@/components/ui/card'; import { Card } from '@/components/ui/card';
import type { Dealer } from '@/lib/mockData'; import type { Dealer } from '@/lib/mockData';
@ -9,7 +9,8 @@ interface DealerSnapshotProps {
} }
export const DealerSnapshot = memo(({ dealer, title = "Dealer Snapshot" }: 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: "Type", value: dealer.dealerType },
{ label: "Total Companies Associated", value: dealer.noOfCompanies }, { label: "Total Companies Associated", value: dealer.noOfCompanies },
{ label: "Active Products", value: dealer.noOfProducts }, { 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: "Avg. Stock Age", value: `${dealer.stockAge} Days` },
{ label: "Aged Stock (>90 Days)", value: `${dealer.agedStock} MT` }, { label: "Aged Stock (>90 Days)", value: `${dealer.agedStock} MT` },
{ label: "Current Stock Quantity", value: `${dealer.currentStock} MT` }, { label: "Current Stock Quantity", value: `${dealer.currentStock} MT` },
]; ], [dealer]);
return ( return (
<Card className="p-4 sm:p-6"> <Card className="p-4 sm:p-6">
@ -69,21 +70,16 @@ interface CompareBusinessSnapshotProps {
data: ManufacturerData; data: ManufacturerData;
} }
export const CompareBusinessSnapshot = memo(({ data }: CompareBusinessSnapshotProps) => { // Helper function outside component to prevent recreation
// Helper function to extract value from API response (handles both formats) const getValue = (value: number | { value: number; unit: string } | undefined, defaultVal: number = 0): number => {
const getValue = (value: number | { value: number; unit: string } | undefined, defaultVal: number = 0): number => {
if (value === undefined) return defaultVal; if (value === undefined) return defaultVal;
if (typeof value === 'number') return value; if (typeof value === 'number') return value;
return value.value || defaultVal; 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;
};
export const CompareBusinessSnapshot = memo(({ data }: CompareBusinessSnapshotProps) => {
// Extract values from API response format or fallback to direct values // Extract values from API response format or fallback to direct values
const snapshotItems = useMemo(() => {
const type = data.type || 'N/A'; const type = data.type || 'N/A';
const totalCompanies = data.total_companies_associated ?? data.totalCompanies ?? 0; const totalCompanies = data.total_companies_associated ?? data.totalCompanies ?? 0;
const activeProducts = data.active_products ?? data.activeProducts ?? 0; const activeProducts = data.active_products ?? data.activeProducts ?? 0;
@ -95,7 +91,7 @@ export const CompareBusinessSnapshot = memo(({ data }: CompareBusinessSnapshotPr
const agedStock = getValue(data.aged_stock_over_90_days ?? data.agedStock, 0); const agedStock = getValue(data.aged_stock_over_90_days ?? data.agedStock, 0);
const currentStock = getValue(data.current_stock_quantity ?? data.currentStock, 0); const currentStock = getValue(data.current_stock_quantity ?? data.currentStock, 0);
const snapshotItems = [ return [
{ label: "Type", value: type }, { label: "Type", value: type },
{ label: "Total Companies Associated", value: totalCompanies }, { label: "Total Companies Associated", value: totalCompanies },
{ label: "Active Products", value: activeProducts }, { label: "Active Products", value: activeProducts },
@ -107,6 +103,7 @@ export const CompareBusinessSnapshot = memo(({ data }: CompareBusinessSnapshotPr
{ label: "Aged Stock (>90 Days)", value: `${agedStock} MT` }, { label: "Aged Stock (>90 Days)", value: `${agedStock} MT` },
{ label: "Current Stock Quantity", value: `${currentStock} MT` }, { label: "Current Stock Quantity", value: `${currentStock} MT` },
]; ];
}, [data]);
return ( return (
<Card className="p-4 sm:p-6"> <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">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">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">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">Total Sales (MT)</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">Total Buy (MT)</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">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">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">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">Aged Stock</th>
<th className="px-4 py-3 text-left text-sm font-medium text-foreground min-w-[200px]">Credit Score</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">{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.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.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">
<td className="px-4 py-3 text-sm text-center">{dealer.buyRating}</td> {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"> <td className="px-4 py-3 text-sm text-center">
{dealer.avgLiquidityCycle > 0 ? `${dealer.avgLiquidityCycle.toFixed(1)} Days` : '0 Days'} {dealer.avgLiquidityCycle > 0 ? `${dealer.avgLiquidityCycle.toFixed(1)} Days` : '0 Days'}
</td> </td>
<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>
</tr> </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'; 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 type { ReactNode } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { authService, type AppUser, type UserRole } from '@/api'; import { authService, type AppUser, type UserRole } from '@/api';
@ -157,8 +157,14 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => {
navigate('/login'); 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 ( return (
<AuthContext.Provider value={{ user, session, userRole, loading, signIn, signOut }}> <AuthContext.Provider value={contextValue}>
{children} {children}
</AuthContext.Provider> </AuthContext.Provider>
); );

View File

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

View File

@ -23,6 +23,7 @@ export interface Dealer {
mobile?: string; mobile?: string;
aadhaar?: string; aadhaar?: string;
dealerLicense?: string; dealerLicense?: string;
isActive?: boolean;
} }
// Get all Indian states using country-state-city // 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 { 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,72 @@ 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,
avgCreditScoreChange: null,
highRiskPercentage: "0.0", 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 // Redirect to login if not authenticated
useEffect(() => { useEffect(() => {
@ -130,6 +185,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 +212,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) => (
@ -165,28 +236,30 @@ const Dashboard = () => {
title="Total Dealers" title="Total Dealers"
value={displayKPIs.totalDealers?.toLocaleString() || "0"} value={displayKPIs.totalDealers?.toLocaleString() || "0"}
icon={Users} icon={Users}
trend="Active in system" trend={getDynamicLabels.totalDealersTrend}
/> />
<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" change={displayKPIs.avgCreditScoreChange}
trendColor="success" changeLabel="vs last month"
changeUnit="%"
/> />
<KPICard <KPICard
title="High-Risk Dealers" title="High-Risk Dealers"
value={`${displayKPIs.highRiskPercentage}%`} value={`${displayKPIs.highRiskPercentage}%`}
icon={AlertTriangle} icon={AlertTriangle}
trend="Score below 500" trend={getDynamicLabels.highRiskTrend}
trendColor="danger" trendColor="danger"
/> />
<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" change={displayKPIs.avgLiquidityCycleChange}
trendColor="success" changeLabel="vs last month"
changeUnit="days"
/> />
</> </>
)} )}
@ -195,8 +268,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}
/> />
@ -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">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">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">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">Total Sales (MT)</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">Total Buy (MT)</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">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">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">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">Aged Stock</th>
<th className="px-4 py-3 text-left text-sm font-medium text-foreground min-w-[200px]">Credit Score</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 { 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 { dealers, getScoreBreakdown, getCreditScoreColor, type Dealer, type ScoreParameter } from "@/lib/mockData";
import { dealerService } from "@/api/services/dealer.service"; import { dealerService } from "@/api/services/dealer.service";
import { useState } from "react"; import { useState } from "react";
@ -85,7 +85,8 @@ const DealerProfile = () => {
stockAge: d.stock_age || 0, stockAge: d.stock_age || 0,
mobile: d.mobile_number || d.mobile, mobile: d.mobile_number || d.mobile,
aadhaar: d.aadhaar, 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'); console.log('Setting compareBusinessData from API');
setCompareBusinessData(compareRes.data); setCompareBusinessData(compareRes.data);
} else { } else {
console.log('Compare API failed or no data, will use manufacturerData fallback'); console.log('Compare API failed or no data, will use fallback data from dealer snapshot');
console.log('manufacturerData:', manufacturerData);
} }
if (mappedDealer) { if (mappedDealer) {
@ -224,11 +224,11 @@ const DealerProfile = () => {
[dealer] [dealer]
); );
// Use seeded random for consistent active status // Get active status from backend data (defaults to true if not provided)
const isActive = useMemo(() => { const isActive = useMemo(() => {
if (!dealer) return false; if (!dealer) return false;
const seed = parseInt(dealer.id.replace('DLR', ''), 10) || 1; // Use isActive from dealer data if available, otherwise default to true
return Math.sin(seed) * 10000 - Math.floor(Math.sin(seed) * 10000) > 0.3; return dealer.isActive !== undefined ? dealer.isActive : true;
}, [dealer]); }, [dealer]);
const isEligible = dealer ? dealer.creditScore >= 500 : false; 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"> <h3 className="text-lg sm:text-xl font-bold text-foreground mb-2">
Month-on-Month Product-wise Score Trends Month-on-Month Product-wise Score Trends
</h3> </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"> <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 Scores are calculated based on various performance metrics. Total weightage per product per month: 1000 points
</p> </p>
@ -276,6 +277,7 @@ const ScoreCard = () => {
{/* Card 4: Footer Note */} {/* Card 4: Footer Note */}
<Card className="p-6"> <Card className="p-6">
{/* TODO: Update note below to reflect only parameters currently considered in score calculation */}
<p className="text-sm text-muted-foreground"> <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. <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> </p>

View File

@ -14,24 +14,5 @@ export default defineConfig({
'@': path.resolve(__dirname, './src'), '@': 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);
});
},
},
},
}, */
}) })