Compare commits
3 Commits
d2d9e0d5c3
...
5566371864
| Author | SHA1 | Date | |
|---|---|---|---|
| 5566371864 | |||
| 8e014a8e67 | |||
| 9aaa70f41a |
25
.dockerignore
Normal file
25
.dockerignore
Normal 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
50
Dockerfile
Normal 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
51
nginx.conf
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@ -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"
|
||||||
},
|
},
|
||||||
|
|||||||
@ -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',
|
||||||
|
|||||||
@ -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`,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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';
|
||||||
|
|||||||
@ -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;
|
||||||
|
});
|
||||||
|
|||||||
@ -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';
|
||||||
|
|||||||
@ -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';
|
||||||
|
|||||||
@ -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';
|
||||||
|
|||||||
@ -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));
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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';
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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';
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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');
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}, */
|
|
||||||
})
|
})
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user