changes updated on integrations 19th jan
This commit is contained in:
parent
883450eafd
commit
4da7f519e8
@ -5,29 +5,47 @@ import axios from 'axios';
|
|||||||
* 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.
|
||||||
*/
|
*/
|
||||||
const axiosInstance = axios.create({
|
const axiosInstance = axios.create({
|
||||||
baseURL: '/api', // This matches the proxy path in vite.config.ts
|
baseURL: 'http://localhost:8003/api', // Direct backend URL (requires CORS on backend)
|
||||||
timeout: 10000,
|
timeout: 30000, // Default timeout (30 seconds)
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a custom axios instance with extended timeout for heavy operations
|
||||||
|
* like fetching large dealer lists
|
||||||
|
*/
|
||||||
|
export const createExtendedTimeoutInstance = (timeout: number = 120000) => {
|
||||||
|
return axios.create({
|
||||||
|
baseURL: 'http://localhost:8003/api',
|
||||||
|
timeout,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper function to add auth token to config
|
||||||
|
const addAuthToken = (config: any) => {
|
||||||
|
const session = localStorage.getItem('dealer360_session');
|
||||||
|
if (session) {
|
||||||
|
try {
|
||||||
|
const { access_token } = JSON.parse(session);
|
||||||
|
if (access_token) {
|
||||||
|
config.headers.Authorization = `Bearer ${access_token}`;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error parsing session for Authorization header', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
};
|
||||||
|
|
||||||
// Request interceptor for adding auth tokens, etc.
|
// Request interceptor for adding auth tokens, etc.
|
||||||
axiosInstance.interceptors.request.use(
|
axiosInstance.interceptors.request.use(
|
||||||
(config) => {
|
(config) => {
|
||||||
// You can get the token from localStorage or a store (e.g., Zustand)
|
return addAuthToken(config);
|
||||||
const session = localStorage.getItem('dealer360_session');
|
|
||||||
if (session) {
|
|
||||||
try {
|
|
||||||
const { access_token } = JSON.parse(session);
|
|
||||||
if (access_token) {
|
|
||||||
config.headers.Authorization = `Bearer ${access_token}`;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error parsing session for Authorization header', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return config;
|
|
||||||
},
|
},
|
||||||
(error) => {
|
(error) => {
|
||||||
return Promise.reject(error);
|
return Promise.reject(error);
|
||||||
@ -65,4 +83,39 @@ axiosInstance.interceptors.response.use(
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Setup interceptors for extended timeout instance
|
||||||
|
export const setupExtendedInstanceInterceptors = (instance: typeof axiosInstance) => {
|
||||||
|
// Request interceptor
|
||||||
|
instance.interceptors.request.use(
|
||||||
|
(config) => {
|
||||||
|
return addAuthToken(config);
|
||||||
|
},
|
||||||
|
(error) => {
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Response interceptor
|
||||||
|
instance.interceptors.response.use(
|
||||||
|
(response) => response,
|
||||||
|
(error) => {
|
||||||
|
if (error.response) {
|
||||||
|
if (error.response.status === 401) {
|
||||||
|
localStorage.removeItem('dealer360_session');
|
||||||
|
if (!window.location.pathname.includes('/login')) {
|
||||||
|
console.warn('Session expired. Redirecting to login.');
|
||||||
|
window.location.href = '/login?expired=true';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.error(`API Error [${error.response.status}]:`, error.response.data);
|
||||||
|
} else if (error.request) {
|
||||||
|
console.error('API No Response:', error.request);
|
||||||
|
} else {
|
||||||
|
console.error('API Request Setup Error:', error.message);
|
||||||
|
}
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export default axiosInstance;
|
export default axiosInstance;
|
||||||
|
|||||||
@ -9,8 +9,18 @@ export const API_ENDPOINTS = {
|
|||||||
REFRESH_TOKEN: '/v1/auth/refresh-token',
|
REFRESH_TOKEN: '/v1/auth/refresh-token',
|
||||||
},
|
},
|
||||||
DEALERS: {
|
DEALERS: {
|
||||||
LIST: '/dealers',
|
LIST: '/v1/dealers/list',
|
||||||
DETAIL: (id: string | number) => `/dealers/${id}`,
|
DETAIL: (id: string | number) => `/v1/dealers/${id}`,
|
||||||
KPI: '/dealers/kpi',
|
KPI: '/v1/dealers/kpi',
|
||||||
|
OVERVIEW: '/v1/dealers/overview',
|
||||||
|
STATES: '/v1/dealers/states',
|
||||||
|
DISTRICTS: '/v1/dealers/districts',
|
||||||
|
SNAPSHOT: (id: string | number) => `/v1/dealerdetails/dealer-snapshot/${id}`,
|
||||||
|
CREDIT_SCORE_TREND: (id: string | number) => `/v1/dealerdetails/credit-score-trend/${id}`,
|
||||||
|
SALES_VS_PURCHASE: (id: string | number) => `/v1/dealerdetails/sales-vs-purchase/${id}`,
|
||||||
|
STOCK_AGE_DISTRIBUTION: (id: string | number) => `/v1/dealerdetails/stock-age-distribution/${id}`,
|
||||||
|
CREDIT_SCORE_BREAKDOWN: (id: string | number) => `/v1/dealerdetails/credit-score-breakdown/${id}`,
|
||||||
|
KEY_INSIGHTS: (id: string | number) => `/v1/dealerdetails/key-insights/${id}`,
|
||||||
|
PRODUCT_WISE_SCORE_TRENDS: (id: string | number) => `/v1/dealerdetails/product-wise-score-trends/${id}`,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
export { default as axiosInstance } from './axiosInstance';
|
export { default as axiosInstance } from './axiosInstance';
|
||||||
export * from './endpoints';
|
export * from './endpoints';
|
||||||
export * from './services/auth.service';
|
export * from './services/auth.service';
|
||||||
|
export * from './services/dealer.service';
|
||||||
export * from './types';
|
export * from './types';
|
||||||
// Export other services as they are created
|
// Export other services as they are created
|
||||||
|
|||||||
236
src/api/services/dealer.service.ts
Normal file
236
src/api/services/dealer.service.ts
Normal file
@ -0,0 +1,236 @@
|
|||||||
|
import axiosInstance, { createExtendedTimeoutInstance, setupExtendedInstanceInterceptors } from '../axiosInstance';
|
||||||
|
import { API_ENDPOINTS } from '../endpoints';
|
||||||
|
import type { ApiResponse, DealerOverviewParams, DealerListParams } from '../types';
|
||||||
|
|
||||||
|
// Create extended timeout instance for dealers list (120 seconds for large queries)
|
||||||
|
const extendedTimeoutInstance = createExtendedTimeoutInstance(120000);
|
||||||
|
setupExtendedInstanceInterceptors(extendedTimeoutInstance);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Service for handling dealer related API calls.
|
||||||
|
*/
|
||||||
|
export const dealerService = {
|
||||||
|
/**
|
||||||
|
* Get dealer overview metrics.
|
||||||
|
* Supports filtering by state, district, dealer_type, and credit score range.
|
||||||
|
* Supports AbortController for request cancellation.
|
||||||
|
*/
|
||||||
|
getOverview: async (
|
||||||
|
params: DealerOverviewParams,
|
||||||
|
signal?: AbortSignal
|
||||||
|
): Promise<ApiResponse<any>> => {
|
||||||
|
try {
|
||||||
|
const response = await axiosInstance.get<ApiResponse<any>>(
|
||||||
|
API_ENDPOINTS.DEALERS.OVERVIEW,
|
||||||
|
{ params, signal }
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
} catch (error: any) {
|
||||||
|
// Handle AbortError gracefully (request was cancelled)
|
||||||
|
if (error.name === 'AbortError' || error.code === 'ERR_CANCELED') {
|
||||||
|
throw new Error('Request cancelled');
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get list of dealers with pagination and filtering.
|
||||||
|
* Uses extended timeout (120s) to handle large queries efficiently.
|
||||||
|
* Supports AbortController for request cancellation.
|
||||||
|
*/
|
||||||
|
getDealersList: async (
|
||||||
|
params: DealerListParams,
|
||||||
|
signal?: AbortSignal
|
||||||
|
): Promise<ApiResponse<any>> => {
|
||||||
|
try {
|
||||||
|
const response = await extendedTimeoutInstance.get<ApiResponse<any>>(
|
||||||
|
API_ENDPOINTS.DEALERS.LIST,
|
||||||
|
{
|
||||||
|
params,
|
||||||
|
signal // Support request cancellation
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
} catch (error: any) {
|
||||||
|
// Handle AbortError gracefully (request was cancelled)
|
||||||
|
if (error.name === 'AbortError' || error.code === 'ERR_CANCELED') {
|
||||||
|
throw new Error('Request cancelled');
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get list of unique states
|
||||||
|
*/
|
||||||
|
getStates: async (): Promise<ApiResponse<string[]>> => {
|
||||||
|
try {
|
||||||
|
const response = await axiosInstance.get<ApiResponse<string[]>>(
|
||||||
|
API_ENDPOINTS.DEALERS.STATES
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get list of districts, optionally filtered by state(s)
|
||||||
|
* Supports AbortController for request cancellation
|
||||||
|
*/
|
||||||
|
getDistricts: async (
|
||||||
|
states?: string[],
|
||||||
|
signal?: AbortSignal
|
||||||
|
): Promise<ApiResponse<string[]>> => {
|
||||||
|
try {
|
||||||
|
const params: any = {};
|
||||||
|
if (states && states.length > 0) {
|
||||||
|
params.state = states.join(',');
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await axiosInstance.get<ApiResponse<any>>(
|
||||||
|
API_ENDPOINTS.DEALERS.DISTRICTS,
|
||||||
|
{ params, signal }
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log("getDistricts response:", response.data);
|
||||||
|
|
||||||
|
let data = response.data.data;
|
||||||
|
let districts: string[] = [];
|
||||||
|
|
||||||
|
if (Array.isArray(data)) {
|
||||||
|
districts = data;
|
||||||
|
} else if (typeof data === 'object' && data !== null) {
|
||||||
|
const arrays = Object.values(data) as string[][];
|
||||||
|
districts = arrays.flat();
|
||||||
|
districts = Array.from(new Set(districts));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort districts alphabetically
|
||||||
|
districts.sort((a, b) => a.localeCompare(b));
|
||||||
|
|
||||||
|
return {
|
||||||
|
...response.data,
|
||||||
|
data: districts
|
||||||
|
};
|
||||||
|
} catch (error: any) {
|
||||||
|
// Handle AbortError gracefully (request was cancelled)
|
||||||
|
if (error.name === 'AbortError' || error.code === 'ERR_CANCELED') {
|
||||||
|
throw new Error('Request cancelled');
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Get single dealer details by ID.
|
||||||
|
*/
|
||||||
|
getDealerDetails: async (id: string | number): Promise<ApiResponse<any>> => {
|
||||||
|
try {
|
||||||
|
const response = await axiosInstance.get<ApiResponse<any>>(
|
||||||
|
API_ENDPOINTS.DEALERS.DETAIL(id)
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get dealer snapshot details.
|
||||||
|
*/
|
||||||
|
getDealerSnapshot: async (id: string | number): Promise<ApiResponse<any>> => {
|
||||||
|
try {
|
||||||
|
const response = await axiosInstance.get<ApiResponse<any>>(
|
||||||
|
API_ENDPOINTS.DEALERS.SNAPSHOT(id)
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get credit score trend.
|
||||||
|
*/
|
||||||
|
getCreditScoreTrend: async (id: string | number): Promise<ApiResponse<any>> => {
|
||||||
|
try {
|
||||||
|
const response = await axiosInstance.get<ApiResponse<any>>(
|
||||||
|
API_ENDPOINTS.DEALERS.CREDIT_SCORE_TREND(id)
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get sales vs purchase trend.
|
||||||
|
*/
|
||||||
|
getSalesVsPurchase: async (id: string | number): Promise<ApiResponse<any>> => {
|
||||||
|
try {
|
||||||
|
const response = await axiosInstance.get<ApiResponse<any>>(
|
||||||
|
API_ENDPOINTS.DEALERS.SALES_VS_PURCHASE(id)
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get stock age distribution.
|
||||||
|
*/
|
||||||
|
getStockAgeDistribution: async (id: string | number): Promise<ApiResponse<any>> => {
|
||||||
|
try {
|
||||||
|
const response = await axiosInstance.get<ApiResponse<any>>(
|
||||||
|
API_ENDPOINTS.DEALERS.STOCK_AGE_DISTRIBUTION(id)
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get credit score breakdown.
|
||||||
|
*/
|
||||||
|
getCreditScoreBreakdown: async (id: string | number): Promise<ApiResponse<any>> => {
|
||||||
|
try {
|
||||||
|
const response = await axiosInstance.get<ApiResponse<any>>(
|
||||||
|
API_ENDPOINTS.DEALERS.CREDIT_SCORE_BREAKDOWN(id)
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get key insights.
|
||||||
|
*/
|
||||||
|
getKeyInsights: async (id: string | number): Promise<ApiResponse<any>> => {
|
||||||
|
try {
|
||||||
|
const response = await axiosInstance.get<ApiResponse<any>>(
|
||||||
|
API_ENDPOINTS.DEALERS.KEY_INSIGHTS(id)
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get product-wise score trends.
|
||||||
|
*/
|
||||||
|
getProductWiseScoreTrends: async (id: string | number): Promise<ApiResponse<any>> => {
|
||||||
|
try {
|
||||||
|
const response = await axiosInstance.get<ApiResponse<any>>(
|
||||||
|
API_ENDPOINTS.DEALERS.PRODUCT_WISE_SCORE_TRENDS(id)
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
@ -3,6 +3,12 @@ export interface ApiResponse<T = any> {
|
|||||||
message: string;
|
message: string;
|
||||||
data: T;
|
data: T;
|
||||||
timestamp: string;
|
timestamp: string;
|
||||||
|
pagination?: {
|
||||||
|
page: number;
|
||||||
|
limit: number;
|
||||||
|
total: number;
|
||||||
|
totalPages: number;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UserData {
|
export interface UserData {
|
||||||
@ -29,3 +35,17 @@ export interface AppUser {
|
|||||||
role: UserRole;
|
role: UserRole;
|
||||||
company_name: string;
|
company_name: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DealerOverviewParams {
|
||||||
|
state?: string | null;
|
||||||
|
district?: string | null;
|
||||||
|
dealer_type?: string;
|
||||||
|
credit_score_min?: number;
|
||||||
|
credit_score_max?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DealerListParams extends DealerOverviewParams {
|
||||||
|
search?: string;
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { memo, useState, useCallback, useMemo, useEffect } from "react";
|
import { memo, useState, useCallback, useEffect, useMemo } from "react";
|
||||||
import { Download, ChevronDown } 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";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
@ -7,13 +7,14 @@ import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover
|
|||||||
import { Checkbox } from "@/components/ui/checkbox";
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Card } from "@/components/ui/card";
|
import { Card } from "@/components/ui/card";
|
||||||
import { indianStates, stateDistricts } from "@/lib/mockData";
|
|
||||||
import { useDebounce } from "@/hooks/useDebounce";
|
import { useDebounce } from "@/hooks/useDebounce";
|
||||||
|
import { dealerService } from "@/api/services/dealer.service";
|
||||||
|
|
||||||
interface SearchFiltersProps {
|
interface SearchFiltersProps {
|
||||||
onSearch: (query: string) => void;
|
onSearch: (query: string) => void;
|
||||||
onFilter: (filters: FilterState) => void;
|
onFilter: (filters: FilterState) => void;
|
||||||
onDownload: () => void;
|
onDownload: () => void;
|
||||||
|
isLoading?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FilterState {
|
export interface FilterState {
|
||||||
@ -39,6 +40,7 @@ const StateCheckbox = memo(({
|
|||||||
id={`state-${state}`}
|
id={`state-${state}`}
|
||||||
checked={isChecked}
|
checked={isChecked}
|
||||||
onCheckedChange={(checked) => onToggle(state, checked === true)}
|
onCheckedChange={(checked) => onToggle(state, checked === true)}
|
||||||
|
className="!border-[#16a34a] !border-2 data-[state=checked]:!bg-[#16a34a] data-[state=checked]:!border-[#16a34a]"
|
||||||
/>
|
/>
|
||||||
<label
|
<label
|
||||||
htmlFor={`state-${state}`}
|
htmlFor={`state-${state}`}
|
||||||
@ -65,6 +67,7 @@ const DistrictCheckbox = memo(({
|
|||||||
id={`district-${district}`}
|
id={`district-${district}`}
|
||||||
checked={isChecked}
|
checked={isChecked}
|
||||||
onCheckedChange={(checked) => onToggle(district, checked === true)}
|
onCheckedChange={(checked) => onToggle(district, checked === true)}
|
||||||
|
className="!border-[#16a34a] !border-2 data-[state=checked]:!bg-[#16a34a] data-[state=checked]:!border-[#16a34a]"
|
||||||
/>
|
/>
|
||||||
<label
|
<label
|
||||||
htmlFor={`district-${district}`}
|
htmlFor={`district-${district}`}
|
||||||
@ -111,10 +114,15 @@ const CreditScoreRange = memo(({
|
|||||||
));
|
));
|
||||||
CreditScoreRange.displayName = 'CreditScoreRange';
|
CreditScoreRange.displayName = 'CreditScoreRange';
|
||||||
|
|
||||||
export const SearchFilters = memo(({ onSearch, onFilter, onDownload }: SearchFiltersProps) => {
|
export const SearchFilters = memo(({ 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);
|
||||||
|
|
||||||
|
// Data for dropdowns
|
||||||
|
const [availableStates, setAvailableStates] = useState<string[]>([]);
|
||||||
|
const [availableDistricts, setAvailableDistricts] = useState<string[]>([]);
|
||||||
|
|
||||||
const [filters, setFilters] = useState<FilterState>({
|
const [filters, setFilters] = useState<FilterState>({
|
||||||
states: [],
|
states: [],
|
||||||
districts: [],
|
districts: [],
|
||||||
@ -136,6 +144,59 @@ export const SearchFilters = memo(({ onSearch, onFilter, onDownload }: SearchFil
|
|||||||
onSearch(debouncedSearchQuery);
|
onSearch(debouncedSearchQuery);
|
||||||
}, [debouncedSearchQuery, onSearch]);
|
}, [debouncedSearchQuery, onSearch]);
|
||||||
|
|
||||||
|
// Fetch states on mount
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchStates = async () => {
|
||||||
|
try {
|
||||||
|
const response = await dealerService.getStates();
|
||||||
|
if (response.success && Array.isArray(response.data)) {
|
||||||
|
setAvailableStates(response.data);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to fetch states", err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
fetchStates();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Debounce states array to prevent rapid-fire requests when multiple states are selected quickly
|
||||||
|
const statesKey = useMemo(() => JSON.stringify([...filters.states].sort()), [filters.states]);
|
||||||
|
const debouncedStatesKey = useDebounce(statesKey, 300);
|
||||||
|
const debouncedStates = useMemo(() => {
|
||||||
|
try {
|
||||||
|
return JSON.parse(debouncedStatesKey);
|
||||||
|
} catch {
|
||||||
|
return filters.states;
|
||||||
|
}
|
||||||
|
}, [debouncedStatesKey, filters.states]);
|
||||||
|
|
||||||
|
// Fetch districts when states change
|
||||||
|
// OPTIMIZATION: Use AbortController + debouncing to cancel duplicate requests and prevent race conditions
|
||||||
|
useEffect(() => {
|
||||||
|
const abortController = new AbortController();
|
||||||
|
|
||||||
|
const fetchDistricts = async () => {
|
||||||
|
try {
|
||||||
|
const response = await dealerService.getDistricts(debouncedStates, abortController.signal);
|
||||||
|
if (!abortController.signal.aborted && response.success && Array.isArray(response.data)) {
|
||||||
|
setAvailableDistricts(response.data);
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
// Don't set state if request was cancelled
|
||||||
|
if (err.name !== 'AbortError' && err.message !== 'Request cancelled') {
|
||||||
|
console.error("Failed to fetch districts", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchDistricts();
|
||||||
|
|
||||||
|
// Cleanup: cancel request if component unmounts or states change
|
||||||
|
return () => {
|
||||||
|
abortController.abort();
|
||||||
|
};
|
||||||
|
}, [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 };
|
const newFilters = { ...filters, [key]: value };
|
||||||
setFilters(newFilters);
|
setFilters(newFilters);
|
||||||
@ -150,6 +211,13 @@ export const SearchFilters = memo(({ onSearch, onFilter, onDownload }: SearchFil
|
|||||||
const newFilters = {
|
const newFilters = {
|
||||||
...filters,
|
...filters,
|
||||||
states: newStates,
|
states: newStates,
|
||||||
|
// If we deselect a state, we should probably clear districts involved with it?
|
||||||
|
// But since we refetch districts based on new states, valid districts will be returned.
|
||||||
|
// We might want to filter out selected districts that are no longer in the available list.
|
||||||
|
// However user might want to keep them if they are still valid.
|
||||||
|
// For now, let's keep it simple and just update states. The district list will update visually.
|
||||||
|
// If we want to be strict, we can filter districts against the new availableDistricts list in the useEffect,
|
||||||
|
// but that requires another effect or logic.
|
||||||
districts: newStates.length === 0 ? [] : filters.districts,
|
districts: newStates.length === 0 ? [] : filters.districts,
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -167,12 +235,6 @@ export const SearchFilters = memo(({ onSearch, onFilter, onDownload }: SearchFil
|
|||||||
onFilter(newFilters);
|
onFilter(newFilters);
|
||||||
}, [onFilter, filters]);
|
}, [onFilter, filters]);
|
||||||
|
|
||||||
// Get available districts based on selected states
|
|
||||||
const availableDistricts = useMemo(() => {
|
|
||||||
if (filters.states.length === 0) return [];
|
|
||||||
return filters.states.flatMap(state => stateDistricts[state] || []);
|
|
||||||
}, [filters.states]);
|
|
||||||
|
|
||||||
const handleDealerTypeChange = useCallback((value: string) => {
|
const handleDealerTypeChange = useCallback((value: string) => {
|
||||||
handleFilterChange('dealerType', value);
|
handleFilterChange('dealerType', value);
|
||||||
}, [handleFilterChange]);
|
}, [handleFilterChange]);
|
||||||
@ -188,13 +250,19 @@ export const SearchFilters = memo(({ onSearch, onFilter, onDownload }: SearchFil
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* Search Bar Row */}
|
{/* Search Bar Row */}
|
||||||
<div className="w-full">
|
<div className="w-full relative">
|
||||||
<Input
|
<Input
|
||||||
placeholder="Search by dealer name, city, state, district, credit score, mobile, aadhaar, or license..."
|
placeholder="Search by dealer name, city, state, district, credit score, mobile, aadhaar, or license..."
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(e) => handleSearchChange(e.target.value)}
|
onChange={(e) => handleSearchChange(e.target.value)}
|
||||||
className="w-full"
|
className="w-full pr-10"
|
||||||
|
disabled={isLoading}
|
||||||
/>
|
/>
|
||||||
|
{isLoading && (
|
||||||
|
<div className="absolute right-3 top-1/2 -translate-y-1/2">
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Filters in a Card */}
|
{/* Filters in a Card */}
|
||||||
@ -213,7 +281,7 @@ export const SearchFilters = memo(({ onSearch, onFilter, onDownload }: SearchFil
|
|||||||
<PopoverContent className="w-[250px] p-0 bg-popover z-50" align="start">
|
<PopoverContent className="w-[250px] p-0 bg-popover z-50" align="start">
|
||||||
<div className="p-2">
|
<div className="p-2">
|
||||||
<div className="max-h-[300px] overflow-y-auto">
|
<div className="max-h-[300px] overflow-y-auto">
|
||||||
{indianStates.map((state) => (
|
{availableStates.map((state) => (
|
||||||
<StateCheckbox
|
<StateCheckbox
|
||||||
key={state}
|
key={state}
|
||||||
state={state}
|
state={state}
|
||||||
@ -263,8 +331,9 @@ export const SearchFilters = memo(({ onSearch, onFilter, onDownload }: SearchFil
|
|||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent className="bg-popover z-50">
|
<SelectContent className="bg-popover z-50">
|
||||||
<SelectItem value="all">All Types</SelectItem>
|
<SelectItem value="all">All Types</SelectItem>
|
||||||
<SelectItem value="Retailer">Retailer</SelectItem>
|
<SelectItem value="retailer">Retailer</SelectItem>
|
||||||
<SelectItem value="Wholesaler">Wholesaler</SelectItem>
|
<SelectItem value="distributor">Distributor</SelectItem>
|
||||||
|
<SelectItem value="wholesaler">Wholesaler</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -3,13 +3,18 @@ import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContai
|
|||||||
import { Card } from '@/components/ui/card';
|
import { Card } from '@/components/ui/card';
|
||||||
import { getCreditScoreTrend } from '@/lib/mockData';
|
import { getCreditScoreTrend } from '@/lib/mockData';
|
||||||
|
|
||||||
interface CreditScoreTrendChartProps {
|
|
||||||
creditScore: number;
|
|
||||||
compact?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const CreditScoreTrendChart = memo(({ creditScore, compact = false }: CreditScoreTrendChartProps) => {
|
|
||||||
const creditTrend = useMemo(() => getCreditScoreTrend(creditScore), [creditScore]);
|
export const CreditScoreTrendChart = memo(({ creditScore, data, compact = false }: { creditScore: number; data?: any[]; compact?: boolean }) => {
|
||||||
|
const creditTrend = useMemo(() => {
|
||||||
|
if (data && data.length > 0) {
|
||||||
|
return data.map(d => ({
|
||||||
|
month: d.month,
|
||||||
|
score: d.credit_score
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
return getCreditScoreTrend(creditScore);
|
||||||
|
}, [creditScore, data]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className={`p-4 ${compact ? '' : 'h-[280px]'}`}>
|
<Card className={`p-4 ${compact ? '' : 'h-[280px]'}`}>
|
||||||
@ -18,14 +23,14 @@ export const CreditScoreTrendChart = memo(({ creditScore, compact = false }: Cre
|
|||||||
<LineChart data={creditTrend}>
|
<LineChart data={creditTrend}>
|
||||||
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" />
|
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" />
|
||||||
<XAxis dataKey="month" stroke="#6b7280" style={{ fontSize: '12px' }} />
|
<XAxis dataKey="month" stroke="#6b7280" style={{ fontSize: '12px' }} />
|
||||||
<YAxis stroke="#6b7280" style={{ fontSize: '12px' }} domain={[600, 850]} />
|
<YAxis stroke="#6b7280" style={{ fontSize: '12px' }} domain={[0, 1000]} />
|
||||||
<Tooltip />
|
<Tooltip />
|
||||||
<Line
|
<Line
|
||||||
type="monotone"
|
type="monotone"
|
||||||
dataKey="score"
|
dataKey="score"
|
||||||
stroke="#16A34A"
|
stroke="#16A34A"
|
||||||
strokeWidth={2}
|
strokeWidth={2}
|
||||||
dot={{ fill: '#16A34A', r: 4 }}
|
dot={{ fill: '#16A34A', r: 4 }}
|
||||||
/>
|
/>
|
||||||
</LineChart>
|
</LineChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
|
|||||||
@ -3,21 +3,24 @@ import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, Responsive
|
|||||||
import { Card } from '@/components/ui/card';
|
import { Card } from '@/components/ui/card';
|
||||||
import { getSalesPurchaseTrend } from '@/lib/mockData';
|
import { getSalesPurchaseTrend } from '@/lib/mockData';
|
||||||
|
|
||||||
interface SalesPurchaseChartProps {
|
|
||||||
totalSales6M: number;
|
|
||||||
totalPurchase6M: number;
|
|
||||||
compact?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const SalesPurchaseChart = memo(({
|
|
||||||
totalSales6M,
|
export const SalesPurchaseChart = memo(({
|
||||||
|
totalSales6M,
|
||||||
totalPurchase6M,
|
totalPurchase6M,
|
||||||
compact = false
|
data,
|
||||||
}: SalesPurchaseChartProps) => {
|
compact = false
|
||||||
const salesPurchaseTrend = useMemo(
|
}: { totalSales6M: number; totalPurchase6M: number; data?: any[]; compact?: boolean }) => {
|
||||||
() => getSalesPurchaseTrend(totalSales6M, totalPurchase6M),
|
const salesPurchaseTrend = useMemo(() => {
|
||||||
[totalSales6M, totalPurchase6M]
|
if (data && data.length > 0) {
|
||||||
);
|
return data.map(d => ({
|
||||||
|
month: d.month,
|
||||||
|
sales: d.sales_mt,
|
||||||
|
purchase: d.purchase_mt
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
return getSalesPurchaseTrend(totalSales6M, totalPurchase6M);
|
||||||
|
}, [totalSales6M, totalPurchase6M, data]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className={`p-4 ${compact ? '' : 'h-[280px]'}`}>
|
<Card className={`p-4 ${compact ? '' : 'h-[280px]'}`}>
|
||||||
@ -29,8 +32,8 @@ export const SalesPurchaseChart = memo(({
|
|||||||
<YAxis stroke="#6b7280" style={{ fontSize: '12px' }} />
|
<YAxis stroke="#6b7280" style={{ fontSize: '12px' }} />
|
||||||
<Tooltip />
|
<Tooltip />
|
||||||
<Legend wrapperStyle={{ fontSize: '12px' }} />
|
<Legend wrapperStyle={{ fontSize: '12px' }} />
|
||||||
<Bar dataKey="sales" fill="#16A34A" name="sales" />
|
|
||||||
<Bar dataKey="purchase" fill="#3B82F6" name="purchase" />
|
<Bar dataKey="purchase" fill="#3B82F6" name="purchase" />
|
||||||
|
<Bar dataKey="sales" fill="#16A34A" name="sales" />
|
||||||
</BarChart>
|
</BarChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@ -5,44 +5,61 @@ import { getStockAgeDistribution } from '@/lib/mockData';
|
|||||||
|
|
||||||
const COLORS = ['#4ade80', '#60a5fa', '#fbbf24', '#f87171', '#a78bfa', '#38bdf8', '#fb923c'];
|
const COLORS = ['#4ade80', '#60a5fa', '#fbbf24', '#f87171', '#a78bfa', '#38bdf8', '#fb923c'];
|
||||||
|
|
||||||
export const StockAgeChart = memo(() => {
|
export const StockAgeChart = memo(({ data }: { data?: any[] }) => {
|
||||||
const stockAgeData = useMemo(() => getStockAgeDistribution(), []);
|
const stockAgeData = useMemo(() => {
|
||||||
|
if (data && data.length > 0) {
|
||||||
|
return data.map(d => ({
|
||||||
|
range: d.age_range,
|
||||||
|
value: d.percentage
|
||||||
|
})).filter(d => d.value > 0); // Only show segments with value > 0
|
||||||
|
}
|
||||||
|
return getStockAgeDistribution();
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="p-4 sm:h-[280px]">
|
<Card className="p-4 sm:h-[280px]">
|
||||||
<h3 className="text-lg font-semibold mb-2 text-foreground">Stock Age Distribution</h3>
|
<h3 className="text-lg font-semibold mb-2 text-foreground">Stock Age Distribution</h3>
|
||||||
<div className="flex flex-col sm:flex-row items-center gap-4">
|
<div className="flex flex-col sm:flex-row items-center gap-x-2 gap-y-4 h-full justify-center">
|
||||||
<ResponsiveContainer width="100%" height={180}>
|
<div className="w-[140px] h-[140px] flex-shrink-0">
|
||||||
<PieChart>
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
<Pie
|
<PieChart>
|
||||||
data={stockAgeData}
|
<Pie
|
||||||
cx="50%"
|
data={stockAgeData}
|
||||||
cy="50%"
|
cx="50%"
|
||||||
labelLine={false}
|
cy="50%"
|
||||||
outerRadius={70}
|
labelLine={false}
|
||||||
innerRadius={35}
|
outerRadius={60}
|
||||||
fill="#8884d8"
|
innerRadius={30}
|
||||||
dataKey="value"
|
fill="#8884d8"
|
||||||
>
|
dataKey="value"
|
||||||
{stockAgeData.map((_, index) => (
|
>
|
||||||
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
|
{stockAgeData.map((_, index) => (
|
||||||
))}
|
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
|
||||||
</Pie>
|
))}
|
||||||
<Tooltip />
|
</Pie>
|
||||||
</PieChart>
|
<Tooltip />
|
||||||
</ResponsiveContainer>
|
</PieChart>
|
||||||
<div className="w-full sm:flex-1 grid grid-cols-2 min-[450px]:grid-cols-3 sm:grid-cols-1 md:grid-cols-2 gap-2 mt-2 sm:mt-0">
|
</ResponsiveContainer>
|
||||||
{stockAgeData.map((entry, index) => (
|
</div>
|
||||||
<div key={index} className="flex items-center gap-1 text-xs">
|
<div className="flex-1 min-w-0 flex flex-col justify-center">
|
||||||
<div
|
<div className="grid grid-cols-2 gap-x-2 gap-y-2">
|
||||||
className="w-2 h-2 rounded-full flex-shrink-0"
|
{stockAgeData.map((entry, index) => (
|
||||||
style={{ backgroundColor: COLORS[index % COLORS.length] }}
|
<div key={index} className="flex items-center gap-1.5 min-w-0">
|
||||||
/>
|
<div
|
||||||
<span className="text-muted-foreground text-[10px] sm:text-xs">
|
className="w-2.5 h-2.5 rounded-sm flex-shrink-0"
|
||||||
{entry.range}: {entry.value}%
|
style={{ backgroundColor: COLORS[index % COLORS.length] }}
|
||||||
</span>
|
/>
|
||||||
</div>
|
<span className="text-[10px] text-foreground font-medium truncate" title={`${entry.range} Days: ${entry.value}%`}>
|
||||||
))}
|
{entry.range} Days: <span className="text-muted-foreground font-normal">{entry.value}%</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{stockAgeData.length === 0 && (
|
||||||
|
<div className="col-span-2 text-center text-sm text-muted-foreground">
|
||||||
|
No stock data available
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@ -15,8 +15,8 @@ export const DealerSnapshot = memo(({ dealer, title = "Dealer Snapshot" }: Deale
|
|||||||
{ label: "Active Products", value: dealer.noOfProducts },
|
{ label: "Active Products", value: dealer.noOfProducts },
|
||||||
{ label: "Total Sales (6M Rolling)", value: `${dealer.totalSales6M} MT` },
|
{ label: "Total Sales (6M Rolling)", value: `${dealer.totalSales6M} MT` },
|
||||||
{ label: "Total Purchase (6M Rolling)", value: `${dealer.totalPurchase6M} MT` },
|
{ label: "Total Purchase (6M Rolling)", value: `${dealer.totalPurchase6M} MT` },
|
||||||
{ label: "Avg. Liquidity Cycle (3M Weighted)", value: `${dealer.avgLiquidityCycle} Days` },
|
{ label: "Avg. Liquidity Cycle (3M Weighted)", value: `${dealer.avgLiquidityCycle > 0 ? dealer.avgLiquidityCycle.toFixed(1) : 0} Days` },
|
||||||
{ label: "Avg. Acknowledgment Cycle (3M Weighted)", value: `${dealer.avgAcknowledgmentCycle} Days` },
|
{ label: "Avg. Acknowledgment Cycle (3M Weighted)", value: `${dealer.avgAcknowledgmentCycle > 0 ? dealer.avgAcknowledgmentCycle.toFixed(1) : 0} Days` },
|
||||||
{ 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` },
|
||||||
@ -67,8 +67,8 @@ export const CompareBusinessSnapshot = memo(({ data }: CompareBusinessSnapshotPr
|
|||||||
{ label: "Active Products", value: data.activeProducts },
|
{ label: "Active Products", value: data.activeProducts },
|
||||||
{ label: "Total Sales (6M Rolling)", value: `${data.totalSales} MT` },
|
{ label: "Total Sales (6M Rolling)", value: `${data.totalSales} MT` },
|
||||||
{ label: "Total Purchase (6M Rolling)", value: `${data.totalPurchase} MT` },
|
{ label: "Total Purchase (6M Rolling)", value: `${data.totalPurchase} MT` },
|
||||||
{ label: "Avg. Liquidity Cycle (3M Weighted)", value: `${data.avgLiquidityCycle} Days` },
|
{ label: "Avg. Liquidity Cycle (3M Weighted)", value: `${data.avgLiquidityCycle > 0 ? data.avgLiquidityCycle.toFixed(1) : 0} Days` },
|
||||||
{ label: "Avg. Acknowledgment Cycle (3M Weighted)", value: `${data.avgAcknowledgmentCycle} Days` },
|
{ label: "Avg. Acknowledgment Cycle (3M Weighted)", value: `${data.avgAcknowledgmentCycle > 0 ? data.avgAcknowledgmentCycle.toFixed(1) : 0} Days` },
|
||||||
{ label: "Avg. Stock Age", value: `${data.avgStockAge} Days` },
|
{ label: "Avg. Stock Age", value: `${data.avgStockAge} Days` },
|
||||||
{ label: "Aged Stock (>90 Days)", value: `${data.agedStock} MT` },
|
{ label: "Aged Stock (>90 Days)", value: `${data.agedStock} MT` },
|
||||||
{ label: "Current Stock Quantity", value: `${data.currentStock} MT` },
|
{ label: "Current Stock Quantity", value: `${data.currentStock} MT` },
|
||||||
|
|||||||
@ -34,8 +34,12 @@ export const DealerTableRow = memo(({
|
|||||||
<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">{dealer.salesRating}</td>
|
||||||
<td className="px-4 py-3 text-sm text-center">{dealer.buyRating}</td>
|
<td className="px-4 py-3 text-sm text-center">{dealer.buyRating}</td>
|
||||||
<td className="px-4 py-3 text-sm text-center">{dealer.avgLiquidityCycle} Days</td>
|
<td className="px-4 py-3 text-sm text-center">
|
||||||
<td className="px-4 py-3 text-sm text-center">{dealer.avgAcknowledgmentCycle} Days</td>
|
{dealer.avgLiquidityCycle > 0 ? `${dealer.avgLiquidityCycle.toFixed(1)} Days` : '0 Days'}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-sm text-center">
|
||||||
|
{dealer.avgAcknowledgmentCycle > 0 ? `${dealer.avgAcknowledgmentCycle.toFixed(1)} Days` : '0 Days'}
|
||||||
|
</td>
|
||||||
<td className="px-4 py-3 text-sm text-center">{dealer.currentStock} MT</td>
|
<td className="px-4 py-3 text-sm text-center">{dealer.currentStock} MT</td>
|
||||||
<td className="px-4 py-3 text-sm text-center">{dealer.agedStock} MT</td>
|
<td className="px-4 py-3 text-sm text-center">{dealer.agedStock} MT</td>
|
||||||
<td className="px-4 py-3 text-sm">
|
<td className="px-4 py-3 text-sm">
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import { memo } from 'react';
|
|||||||
import { Lightbulb } from 'lucide-react';
|
import { Lightbulb } from 'lucide-react';
|
||||||
import { Card } from '@/components/ui/card';
|
import { Card } from '@/components/ui/card';
|
||||||
|
|
||||||
interface Insight {
|
export interface Insight {
|
||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
type: 'success' | 'warning';
|
type: 'success' | 'warning';
|
||||||
@ -10,6 +10,7 @@ interface Insight {
|
|||||||
|
|
||||||
interface KeyInsightsProps {
|
interface KeyInsightsProps {
|
||||||
compact?: boolean;
|
compact?: boolean;
|
||||||
|
insights?: Insight[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_INSIGHTS: Insight[] = [
|
const DEFAULT_INSIGHTS: Insight[] = [
|
||||||
@ -19,7 +20,10 @@ const DEFAULT_INSIGHTS: Insight[] = [
|
|||||||
{ title: "Fast Acknowledgment", description: "Quick acknowledgment in 3 days", type: "success" },
|
{ title: "Fast Acknowledgment", description: "Quick acknowledgment in 3 days", type: "success" },
|
||||||
];
|
];
|
||||||
|
|
||||||
export const KeyInsights = memo(({ compact = false }: KeyInsightsProps) => {
|
export const KeyInsights = memo(({ compact = false, insights }: KeyInsightsProps) => {
|
||||||
|
// Use provided insights or fall back to defaults
|
||||||
|
const displayInsights = insights && insights.length > 0 ? insights : DEFAULT_INSIGHTS;
|
||||||
|
|
||||||
if (compact) {
|
if (compact) {
|
||||||
return (
|
return (
|
||||||
<Card className="p-4">
|
<Card className="p-4">
|
||||||
@ -28,18 +32,16 @@ export const KeyInsights = memo(({ compact = false }: KeyInsightsProps) => {
|
|||||||
<h3 className="text-lg font-semibold text-foreground">Key Insights</h3>
|
<h3 className="text-lg font-semibold text-foreground">Key Insights</h3>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{DEFAULT_INSIGHTS.map((insight, idx) => (
|
{displayInsights.map((insight, idx) => (
|
||||||
<div
|
<div
|
||||||
key={idx}
|
key={idx}
|
||||||
className={`p-2 rounded-lg ${
|
className={`p-2 rounded-lg ${insight.type === 'success'
|
||||||
insight.type === 'success'
|
? 'bg-success/10 border border-success/20'
|
||||||
? 'bg-success/10 border border-success/20'
|
|
||||||
: 'bg-warning/10 border border-warning/20'
|
: 'bg-warning/10 border border-warning/20'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<h4 className={`font-semibold text-xs mb-0.5 ${
|
<h4 className={`font-semibold text-xs mb-0.5 ${insight.type === 'success' ? 'text-success' : 'text-warning'
|
||||||
insight.type === 'success' ? 'text-success' : 'text-warning'
|
}`}>
|
||||||
}`}>
|
|
||||||
{insight.title}
|
{insight.title}
|
||||||
</h4>
|
</h4>
|
||||||
<p className="text-xs text-muted-foreground">{insight.description}</p>
|
<p className="text-xs text-muted-foreground">{insight.description}</p>
|
||||||
@ -57,18 +59,16 @@ export const KeyInsights = memo(({ compact = false }: KeyInsightsProps) => {
|
|||||||
<h2 className="text-xl font-semibold text-foreground">Key Insights</h2>
|
<h2 className="text-xl font-semibold text-foreground">Key Insights</h2>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{DEFAULT_INSIGHTS.map((insight, idx) => (
|
{displayInsights.map((insight, idx) => (
|
||||||
<div
|
<div
|
||||||
key={idx}
|
key={idx}
|
||||||
className={`p-4 rounded-lg ${
|
className={`p-4 rounded-lg ${insight.type === 'success'
|
||||||
insight.type === 'success'
|
? 'bg-success/10 border border-success/20'
|
||||||
? 'bg-success/10 border border-success/20'
|
|
||||||
: 'bg-warning/10 border border-warning/20'
|
: 'bg-warning/10 border border-warning/20'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<h3 className={`font-semibold mb-1 ${
|
<h3 className={`font-semibold mb-1 ${insight.type === 'success' ? 'text-success' : 'text-warning'
|
||||||
insight.type === 'success' ? 'text-success' : 'text-warning'
|
}`}>
|
||||||
}`}>
|
|
||||||
{insight.title}
|
{insight.title}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-sm text-muted-foreground">{insight.description}</p>
|
<p className="text-sm text-muted-foreground">{insight.description}</p>
|
||||||
@ -80,3 +80,4 @@ export const KeyInsights = memo(({ compact = false }: KeyInsightsProps) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
KeyInsights.displayName = 'KeyInsights';
|
KeyInsights.displayName = 'KeyInsights';
|
||||||
|
|
||||||
|
|||||||
@ -3,5 +3,6 @@ export { DealerTableRow } from './DealerTableRow';
|
|||||||
export { DealerProfileHeader } from './DealerProfileHeader';
|
export { DealerProfileHeader } from './DealerProfileHeader';
|
||||||
export { DealerSnapshot, CompareBusinessSnapshot } from './DealerSnapshot';
|
export { DealerSnapshot, CompareBusinessSnapshot } from './DealerSnapshot';
|
||||||
export { CreditScoreBreakdown } from './CreditScoreBreakdown';
|
export { CreditScoreBreakdown } from './CreditScoreBreakdown';
|
||||||
export { KeyInsights } from './KeyInsights';
|
export { KeyInsights, type Insight } from './KeyInsights';
|
||||||
export { ActivityTimeline } from './ActivityTimeline';
|
export { ActivityTimeline } from './ActivityTimeline';
|
||||||
|
|
||||||
|
|||||||
@ -40,10 +40,24 @@ const mapUserIdToRole = (userId: string): UserRole => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const AuthProvider = ({ children }: { children: ReactNode }) => {
|
export const AuthProvider = ({ children }: { children: ReactNode }) => {
|
||||||
const [user, setUser] = useState<AppUser | null>(null);
|
// OPTIMIZATION: Initialize from localStorage synchronously to avoid blocking render
|
||||||
const [session, setSession] = useState<AuthSession | null>(null);
|
const getInitialSession = (): AuthSession | null => {
|
||||||
const [userRole, setUserRole] = useState<UserRole>(null);
|
try {
|
||||||
const [loading, setLoading] = useState(true);
|
const storedSession = localStorage.getItem('dealer360_session');
|
||||||
|
if (storedSession) {
|
||||||
|
return JSON.parse(storedSession) as AuthSession;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
localStorage.removeItem('dealer360_session');
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const initialSession = getInitialSession();
|
||||||
|
const [user, setUser] = useState<AppUser | null>(initialSession?.user || null);
|
||||||
|
const [session, setSession] = useState<AuthSession | null>(initialSession);
|
||||||
|
const [userRole, setUserRole] = useState<UserRole>(initialSession?.user?.role || null);
|
||||||
|
const [loading, setLoading] = useState(false); // Start as false since we sync synchronously
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
// Helper to sync state from localStorage
|
// Helper to sync state from localStorage
|
||||||
@ -66,12 +80,11 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => {
|
|||||||
setUser(null);
|
setUser(null);
|
||||||
setUserRole(null);
|
setUserRole(null);
|
||||||
}
|
}
|
||||||
setLoading(false);
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Initial sync
|
// Initial sync (already done synchronously, but keep for consistency)
|
||||||
syncStateFromStorage();
|
// This is mainly for storage event listeners
|
||||||
|
|
||||||
// Listen for storage changes (multi-tab support)
|
// Listen for storage changes (multi-tab support)
|
||||||
const handleStorageChange = (e: StorageEvent) => {
|
const handleStorageChange = (e: StorageEvent) => {
|
||||||
|
|||||||
149
src/hooks/useDealerOverview.ts
Normal file
149
src/hooks/useDealerOverview.ts
Normal file
@ -0,0 +1,149 @@
|
|||||||
|
import { useState, useEffect, useRef, useMemo } from 'react';
|
||||||
|
import { dealerService } from '../api';
|
||||||
|
import type { FilterState } from '../components/SearchFilters';
|
||||||
|
import { useDebounce } from './useDebounce';
|
||||||
|
|
||||||
|
interface DealerOverviewMetrics {
|
||||||
|
totalDealers: number;
|
||||||
|
avgCreditScore: number;
|
||||||
|
highRiskPercentage: string;
|
||||||
|
avgLiquidityCycle: number;
|
||||||
|
// Add trend details if available in API response, for now we map what we can
|
||||||
|
// The UI expects detailed trends which might not be in the simple overview stats
|
||||||
|
// We'll see what the API returns.
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseDealerOverviewReturn {
|
||||||
|
metrics: DealerOverviewMetrics | null;
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDealerOverview(filters: FilterState, searchQuery: string): UseDealerOverviewReturn {
|
||||||
|
const [metrics, setMetrics] = useState<DealerOverviewMetrics | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true); // Start with loading true for initial load
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Use refs to track AbortController for request cancellation
|
||||||
|
const abortControllerRef = useRef<AbortController | null>(null);
|
||||||
|
const isInitialMount = useRef(true);
|
||||||
|
|
||||||
|
// Check if filters are empty (initial state)
|
||||||
|
const hasActiveFilters = useMemo(() => {
|
||||||
|
return filters.states.length > 0 ||
|
||||||
|
filters.districts.length > 0 ||
|
||||||
|
filters.dealerType !== 'all' ||
|
||||||
|
filters.minCreditScore > 0 ||
|
||||||
|
filters.maxCreditScore < 1000 ||
|
||||||
|
searchQuery.trim().length > 0;
|
||||||
|
}, [filters, searchQuery]);
|
||||||
|
|
||||||
|
// Debounce search query to avoid excessive API calls (500ms delay)
|
||||||
|
// But skip debounce on initial mount if no filters
|
||||||
|
const debouncedSearchQuery = useDebounce(searchQuery, hasActiveFilters && !isInitialMount.current ? 500 : 0);
|
||||||
|
|
||||||
|
// Store the last stable filters (updated when debounce completes)
|
||||||
|
const stableFiltersRef = useRef<FilterState>(filters);
|
||||||
|
|
||||||
|
// Serialize filters for stable debouncing
|
||||||
|
const filtersKey = useMemo(() =>
|
||||||
|
JSON.stringify({
|
||||||
|
states: [...filters.states].sort(),
|
||||||
|
districts: [...filters.districts].sort(),
|
||||||
|
dealerType: filters.dealerType,
|
||||||
|
minCreditScore: filters.minCreditScore,
|
||||||
|
maxCreditScore: filters.maxCreditScore,
|
||||||
|
}),
|
||||||
|
[filters]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Debounce the serialized filters key (300ms delay)
|
||||||
|
// But skip debounce on initial mount if no filters
|
||||||
|
const debouncedFiltersKey = useDebounce(filtersKey, hasActiveFilters && !isInitialMount.current ? 300 : 0);
|
||||||
|
|
||||||
|
// Update stable filters when debounced key changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (debouncedFiltersKey === filtersKey) {
|
||||||
|
stableFiltersRef.current = filters;
|
||||||
|
}
|
||||||
|
}, [debouncedFiltersKey, filtersKey, filters]);
|
||||||
|
|
||||||
|
// Use stable filters for API calls
|
||||||
|
const debouncedFilters = useMemo(() => {
|
||||||
|
return debouncedFiltersKey === filtersKey ? filters : stableFiltersRef.current;
|
||||||
|
}, [debouncedFiltersKey, filtersKey, filters]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Cancel any pending request when filters/search change
|
||||||
|
if (abortControllerRef.current) {
|
||||||
|
abortControllerRef.current.abort();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new AbortController for this request
|
||||||
|
const abortController = new AbortController();
|
||||||
|
abortControllerRef.current = abortController;
|
||||||
|
|
||||||
|
const fetchOverview = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
// Map FilterState to API params
|
||||||
|
const params = {
|
||||||
|
state: debouncedFilters.states.length > 0 ? debouncedFilters.states.join(',') : null,
|
||||||
|
district: debouncedFilters.districts.length > 0 ? debouncedFilters.districts.join(',') : null,
|
||||||
|
dealer_type: debouncedFilters.dealerType === 'all' ? undefined : debouncedFilters.dealerType,
|
||||||
|
credit_score_min: debouncedFilters.minCreditScore,
|
||||||
|
credit_score_max: debouncedFilters.maxCreditScore,
|
||||||
|
search: debouncedSearchQuery || undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await dealerService.getOverview(params, abortController.signal);
|
||||||
|
|
||||||
|
// Check if request was cancelled
|
||||||
|
if (abortController.signal.aborted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.success && response.data) {
|
||||||
|
// Map API response to UI metrics
|
||||||
|
const data = response.data;
|
||||||
|
|
||||||
|
setMetrics({
|
||||||
|
totalDealers: data.totalDealers || data.total_dealers || 0,
|
||||||
|
avgCreditScore: data.avgCreditScore || data.avg_credit_score || 0,
|
||||||
|
highRiskPercentage: data.highRiskPercentage || data.high_risk_dealers_percentage || "0.0",
|
||||||
|
avgLiquidityCycle: data.avgLiquidityCycle || data.avg_liquidity_cycle_days || data.avg_liquidity_cycle || 0
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setError(response.message || 'Failed to fetch overview data');
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
// Don't set error if request was cancelled
|
||||||
|
if (err.message === 'Request cancelled' || err.name === 'AbortError') {
|
||||||
|
console.log('Overview request cancelled');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error('Error fetching dealer overview:', err);
|
||||||
|
setError(err.message || 'An error occurred');
|
||||||
|
} finally {
|
||||||
|
// Only update loading state if request wasn't cancelled
|
||||||
|
if (!abortController.signal.aborted) {
|
||||||
|
setLoading(false);
|
||||||
|
isInitialMount.current = false; // Mark initial mount as complete
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchOverview();
|
||||||
|
|
||||||
|
// Cleanup: cancel request on unmount or when dependencies change
|
||||||
|
return () => {
|
||||||
|
if (abortControllerRef.current) {
|
||||||
|
abortControllerRef.current.abort();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [debouncedFilters, debouncedSearchQuery]);
|
||||||
|
|
||||||
|
return { metrics, loading, error };
|
||||||
|
}
|
||||||
198
src/hooks/useDealersList.ts
Normal file
198
src/hooks/useDealersList.ts
Normal file
@ -0,0 +1,198 @@
|
|||||||
|
import { useState, useEffect, useRef, useMemo } from 'react';
|
||||||
|
import { dealerService } from '../api';
|
||||||
|
import type { FilterState } from '../components/SearchFilters';
|
||||||
|
import type { Dealer } from '@/lib/mockData';
|
||||||
|
import { useDebounce } from './useDebounce';
|
||||||
|
|
||||||
|
interface UseDealersListReturn {
|
||||||
|
dealers: Dealer[];
|
||||||
|
totalCount: number;
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDealersList(
|
||||||
|
filters: FilterState,
|
||||||
|
searchQuery: string,
|
||||||
|
page: number = 1,
|
||||||
|
limit: number = 100
|
||||||
|
): UseDealersListReturn {
|
||||||
|
const [dealers, setDealers] = useState<Dealer[]>([]);
|
||||||
|
const [totalCount, setTotalCount] = useState(0);
|
||||||
|
const [loading, setLoading] = useState(true); // Start with loading true for initial load
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Use refs to track AbortController for request cancellation
|
||||||
|
const abortControllerRef = useRef<AbortController | null>(null);
|
||||||
|
const isInitialMount = useRef(true);
|
||||||
|
|
||||||
|
// Check if filters are empty (initial state)
|
||||||
|
const hasActiveFilters = useMemo(() => {
|
||||||
|
return filters.states.length > 0 ||
|
||||||
|
filters.districts.length > 0 ||
|
||||||
|
filters.dealerType !== 'all' ||
|
||||||
|
filters.minCreditScore > 0 ||
|
||||||
|
filters.maxCreditScore < 1000 ||
|
||||||
|
searchQuery.trim().length > 0;
|
||||||
|
}, [filters, searchQuery]);
|
||||||
|
|
||||||
|
// Debounce search query to avoid excessive API calls (500ms delay)
|
||||||
|
// But skip debounce on initial mount if no filters
|
||||||
|
const debouncedSearchQuery = useDebounce(searchQuery, hasActiveFilters && !isInitialMount.current ? 500 : 0);
|
||||||
|
|
||||||
|
// Store the last stable filters (updated when debounce completes)
|
||||||
|
const stableFiltersRef = useRef<FilterState>(filters);
|
||||||
|
|
||||||
|
// Serialize filters for stable debouncing (objects need serialization for proper comparison)
|
||||||
|
const filtersKey = useMemo(() =>
|
||||||
|
JSON.stringify({
|
||||||
|
states: [...filters.states].sort(),
|
||||||
|
districts: [...filters.districts].sort(),
|
||||||
|
dealerType: filters.dealerType,
|
||||||
|
minCreditScore: filters.minCreditScore,
|
||||||
|
maxCreditScore: filters.maxCreditScore,
|
||||||
|
}),
|
||||||
|
[filters]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Debounce the serialized filters key (300ms delay)
|
||||||
|
// But skip debounce on initial mount if no filters
|
||||||
|
const debouncedFiltersKey = useDebounce(filtersKey, hasActiveFilters && !isInitialMount.current ? 300 : 0);
|
||||||
|
|
||||||
|
// Update stable filters when debounced key changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (debouncedFiltersKey === filtersKey) {
|
||||||
|
stableFiltersRef.current = filters;
|
||||||
|
}
|
||||||
|
}, [debouncedFiltersKey, filtersKey, filters]);
|
||||||
|
|
||||||
|
// Use stable filters for API calls (memoized to prevent unnecessary re-renders)
|
||||||
|
const debouncedFilters = useMemo(() => {
|
||||||
|
return debouncedFiltersKey === filtersKey ? filters : stableFiltersRef.current;
|
||||||
|
}, [debouncedFiltersKey, filtersKey, filters]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Cancel any pending request when filters/search/page/limit change
|
||||||
|
if (abortControllerRef.current) {
|
||||||
|
abortControllerRef.current.abort();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new AbortController for this request
|
||||||
|
const abortController = new AbortController();
|
||||||
|
abortControllerRef.current = abortController;
|
||||||
|
|
||||||
|
const fetchDealers = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
// Map FilterState to API params
|
||||||
|
const params = {
|
||||||
|
state: debouncedFilters.states.length > 0 ? debouncedFilters.states.join(',') : null,
|
||||||
|
district: debouncedFilters.districts.length > 0 ? debouncedFilters.districts.join(',') : null,
|
||||||
|
dealer_type: debouncedFilters.dealerType === 'all' ? undefined : debouncedFilters.dealerType,
|
||||||
|
credit_score_min: debouncedFilters.minCreditScore,
|
||||||
|
credit_score_max: debouncedFilters.maxCreditScore,
|
||||||
|
search: debouncedSearchQuery || undefined,
|
||||||
|
page,
|
||||||
|
limit
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await dealerService.getDealersList(params, abortController.signal);
|
||||||
|
|
||||||
|
if (response.success && response.data) {
|
||||||
|
// Assuming API returns { dealers: [], total: number }
|
||||||
|
// OR it returns the list directly and total in metadata?
|
||||||
|
// Common pattern: data: { list: [], total: 100 } OR data: [] with x-total-count header?
|
||||||
|
// Let's assume data.dealers and data.total based on typical patterns.
|
||||||
|
// If response.data is an array, then it's just the list.
|
||||||
|
|
||||||
|
let fetchedDealers: any[] = [];
|
||||||
|
let total = 0;
|
||||||
|
|
||||||
|
if (Array.isArray(response.data)) {
|
||||||
|
fetchedDealers = response.data;
|
||||||
|
// Use pagination info if available, otherwise fallback to array length
|
||||||
|
total = response.pagination?.total || response.data.length;
|
||||||
|
} else if (response.data.dealers) {
|
||||||
|
fetchedDealers = response.data.dealers;
|
||||||
|
total = response.data.total || response.data.total_count || 0;
|
||||||
|
} else if (response.data.data) { // Laravel style pagination sometimes
|
||||||
|
fetchedDealers = response.data.data;
|
||||||
|
total = response.data.total;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map snake_case to camelCase specific to Dealer interface
|
||||||
|
const mappedDealers: Dealer[] = fetchedDealers.map((d: any) => ({
|
||||||
|
id: d.id || d.dealer_id || `DLR${Math.random().toString(36).substr(2, 9)}`,
|
||||||
|
state: d.state,
|
||||||
|
district: d.district,
|
||||||
|
city: d.city || d.district, // Fallback
|
||||||
|
dealerName: d.dealer_name || d.name,
|
||||||
|
mfmsId: d.mfms_id,
|
||||||
|
noOfProducts: d.no_of_products || d.products_count || 0,
|
||||||
|
noOfCompanies: d.no_of_companies || d.companies_count || 0,
|
||||||
|
salesRating: d.sales_rating || 0,
|
||||||
|
buyRating: d.buy_rating || 0,
|
||||||
|
// Map backend field names to frontend field names
|
||||||
|
// Backend returns: liquidity_cycle_days, ack_cycle_days, current_stock_mt, aged_stock_mt
|
||||||
|
// Round to 1 decimal place for better readability
|
||||||
|
avgLiquidityCycle: Math.round((d.liquidity_cycle_days ?? d.avg_liquidity_cycle ?? 0) * 10) / 10,
|
||||||
|
avgAcknowledgmentCycle: Math.round((d.ack_cycle_days ?? d.avg_acknowledgment_cycle ?? 0) * 10) / 10,
|
||||||
|
currentStock: d.current_stock_mt ?? d.current_stock ?? 0,
|
||||||
|
agedStock: d.aged_stock_mt ?? d.aged_stock ?? 0,
|
||||||
|
creditScore: d.credit_score || 0,
|
||||||
|
dealerType: d.dealer_type || "Retailer", // Default
|
||||||
|
totalSales6M: d.total_sales_6m || 0,
|
||||||
|
totalPurchase6M: d.total_purchase_6m || 0,
|
||||||
|
stockAge: d.stock_age || 0,
|
||||||
|
mobile: d.mobile_number || d.mobile,
|
||||||
|
aadhaar: d.aadhaar,
|
||||||
|
dealerLicense: d.dealer_license || d.license_number
|
||||||
|
}));
|
||||||
|
|
||||||
|
setDealers(mappedDealers);
|
||||||
|
setTotalCount(total);
|
||||||
|
} else {
|
||||||
|
setError(response.message || 'Failed to fetch dealers');
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
// Don't set error if request was cancelled
|
||||||
|
if (err.message === 'Request cancelled' || err.name === 'AbortError') {
|
||||||
|
console.log('Request cancelled');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error('Error fetching dealers:', err);
|
||||||
|
|
||||||
|
// Better error messages for timeout
|
||||||
|
let errorMessage = 'An error occurred';
|
||||||
|
if (err.code === 'ECONNABORTED' || err.message?.includes('timeout')) {
|
||||||
|
errorMessage = 'Request timed out. The query may be too large. Try applying more filters.';
|
||||||
|
} else if (err.message) {
|
||||||
|
errorMessage = err.message;
|
||||||
|
} else if (err.response?.data?.message) {
|
||||||
|
errorMessage = err.response.data.message;
|
||||||
|
}
|
||||||
|
|
||||||
|
setError(errorMessage);
|
||||||
|
} finally {
|
||||||
|
// Only update loading state if request wasn't cancelled
|
||||||
|
if (!abortController.signal.aborted) {
|
||||||
|
setLoading(false);
|
||||||
|
isInitialMount.current = false; // Mark initial mount as complete
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchDealers();
|
||||||
|
|
||||||
|
// Cleanup: cancel request on unmount or when dependencies change
|
||||||
|
return () => {
|
||||||
|
if (abortControllerRef.current) {
|
||||||
|
abortControllerRef.current.abort();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [debouncedFilters, debouncedSearchQuery, page, limit]);
|
||||||
|
|
||||||
|
return { dealers, totalCount, loading, error };
|
||||||
|
}
|
||||||
@ -7,29 +7,53 @@ 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";
|
||||||
import { LoadingSpinner } from "@/components/common/LoadingSpinner";
|
import { LoadingSpinner } from "@/components/common/LoadingSpinner";
|
||||||
import { dealers } from "@/lib/mockData";
|
// import { dealers } from "@/lib/mockData"; // Removed unused import
|
||||||
import { useToast } from "@/components/ui/use-toast";
|
import { useToast } from "@/components/ui/use-toast";
|
||||||
import { useAuth } from "@/hooks/useAuth";
|
import { useAuth } from "@/hooks/useAuth";
|
||||||
import { useDealerFilters } from "@/hooks/useDealerFilters";
|
import { useDealerFilters } from "@/hooks/useDealerFilters";
|
||||||
import { useDealerKPIs } from "@/hooks/useDealerKPIs";
|
|
||||||
|
|
||||||
|
import { useDealerOverview } from "@/hooks/useDealerOverview";
|
||||||
|
|
||||||
|
import { useDealersList } from "@/hooks/useDealersList";
|
||||||
|
|
||||||
|
const ITEMS_PER_PAGE = 100;
|
||||||
|
|
||||||
const Dashboard = () => {
|
const Dashboard = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const { user, signOut, loading } = useAuth();
|
const { user, signOut, loading } = useAuth();
|
||||||
|
|
||||||
// Custom hooks for filtering and KPIs
|
// Custom hooks for filtering and KPIs (State Management only)
|
||||||
const {
|
const {
|
||||||
|
searchQuery,
|
||||||
setSearchQuery,
|
setSearchQuery,
|
||||||
|
filters,
|
||||||
setFilters,
|
setFilters,
|
||||||
currentPage,
|
currentPage,
|
||||||
setCurrentPage,
|
setCurrentPage,
|
||||||
filteredDealers,
|
} = useDealerFilters({ dealers: [], itemsPerPage: ITEMS_PER_PAGE });
|
||||||
paginatedDealers,
|
|
||||||
totalPages,
|
|
||||||
} = useDealerFilters({ dealers, itemsPerPage: 100 });
|
|
||||||
|
|
||||||
const kpis = useDealerKPIs(dealers);
|
// API Integration for Dealers List
|
||||||
|
const { dealers: apiDealers, totalCount, loading: listLoading, error: listError } = useDealersList(
|
||||||
|
filters,
|
||||||
|
searchQuery,
|
||||||
|
currentPage,
|
||||||
|
ITEMS_PER_PAGE
|
||||||
|
);
|
||||||
|
|
||||||
|
// API Integration for Overview Metrics
|
||||||
|
const { metrics, loading: overviewLoading } = useDealerOverview(filters, searchQuery);
|
||||||
|
|
||||||
|
const totalPages = Math.ceil(totalCount / ITEMS_PER_PAGE);
|
||||||
|
|
||||||
|
// Fallback to 0 if metrics not yet loaded
|
||||||
|
const displayKPIs = metrics || {
|
||||||
|
totalDealers: 0,
|
||||||
|
avgCreditScore: 0,
|
||||||
|
highRiskPercentage: "0.0",
|
||||||
|
avgLiquidityCycle: 0
|
||||||
|
};
|
||||||
|
|
||||||
// Redirect to login if not authenticated
|
// Redirect to login if not authenticated
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -61,15 +85,8 @@ const Dashboard = () => {
|
|||||||
});
|
});
|
||||||
}, [toast]);
|
}, [toast]);
|
||||||
|
|
||||||
// Loading state
|
// Show UI immediately even if auth is loading (will redirect if not authenticated)
|
||||||
if (loading) {
|
// This provides better UX - user sees the page structure immediately
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-[#E8F5E9]/30 backdrop-blur-sm flex items-center justify-center font-poppins">
|
|
||||||
<LoadingSpinner size="lg" label="Initializing Dashboard..." />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-background font-poppins">
|
<div className="min-h-screen bg-background font-poppins">
|
||||||
<DashboardHeader
|
<DashboardHeader
|
||||||
@ -84,35 +101,50 @@ const Dashboard = () => {
|
|||||||
<p className="text-sm sm:text-base text-muted-foreground">Comprehensive credit analysis and dealer performance metrics</p>
|
<p className="text-sm sm:text-base text-muted-foreground">Comprehensive credit analysis and dealer performance metrics</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* KPI Cards */}
|
{/* KPI Cards - Always show skeleton on initial load for better perceived performance */}
|
||||||
<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">
|
||||||
<KPICard
|
{overviewLoading || loading ? (
|
||||||
title="Total Dealers"
|
// Show loading skeleton for all cards
|
||||||
value="2,60,000"
|
<>
|
||||||
icon={Users}
|
{[1, 2, 3, 4].map((i) => (
|
||||||
trend="Active in system"
|
<div key={i} className="bg-card rounded-lg border p-6 animate-pulse">
|
||||||
/>
|
<div className="h-4 w-24 bg-muted rounded mb-4"></div>
|
||||||
<KPICard
|
<div className="h-8 w-32 bg-muted rounded mb-2"></div>
|
||||||
title="Avg Credit Score"
|
<div className="h-3 w-40 bg-muted rounded"></div>
|
||||||
value={kpis.avgCreditScore}
|
</div>
|
||||||
icon={TrendingUp}
|
))}
|
||||||
trend="↑ 2.3% from last month"
|
</>
|
||||||
trendColor="success"
|
) : (
|
||||||
/>
|
<>
|
||||||
<KPICard
|
<KPICard
|
||||||
title="High-Risk Dealers"
|
title="Total Dealers"
|
||||||
value={`${kpis.highRiskPercentage}%`}
|
value={displayKPIs.totalDealers?.toLocaleString() || "0"}
|
||||||
icon={AlertTriangle}
|
icon={Users}
|
||||||
trend="Score below 500"
|
trend="Active in system"
|
||||||
trendColor="danger"
|
/>
|
||||||
/>
|
<KPICard
|
||||||
<KPICard
|
title="Avg Credit Score"
|
||||||
title="Avg Liquidity Cycle"
|
value={displayKPIs.avgCreditScore}
|
||||||
value={`${kpis.avgLiquidityCycle} Days`}
|
icon={TrendingUp}
|
||||||
icon={Clock}
|
trend="↑ 2.3% from last month"
|
||||||
trend="↓ 1.8 days improved"
|
trendColor="success"
|
||||||
trendColor="success"
|
/>
|
||||||
/>
|
<KPICard
|
||||||
|
title="High-Risk Dealers"
|
||||||
|
value={`${displayKPIs.highRiskPercentage}%`}
|
||||||
|
icon={AlertTriangle}
|
||||||
|
trend="Score below 500"
|
||||||
|
trendColor="danger"
|
||||||
|
/>
|
||||||
|
<KPICard
|
||||||
|
title="Avg Liquidity Cycle"
|
||||||
|
value={`${displayKPIs.avgLiquidityCycle} Days`}
|
||||||
|
icon={Clock}
|
||||||
|
trend="↓ 1.8 days improved"
|
||||||
|
trendColor="success"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Search & Filters */}
|
{/* Search & Filters */}
|
||||||
@ -121,16 +153,66 @@ const Dashboard = () => {
|
|||||||
onSearch={setSearchQuery}
|
onSearch={setSearchQuery}
|
||||||
onFilter={setFilters}
|
onFilter={setFilters}
|
||||||
onDownload={handleDownload}
|
onDownload={handleDownload}
|
||||||
|
isLoading={listLoading}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Results Summary */}
|
{/* Results Summary */}
|
||||||
<div className="mb-4 text-sm text-muted-foreground">
|
<div className="mb-4 text-sm text-muted-foreground">
|
||||||
Showing {paginatedDealers.length} of {filteredDealers.length} dealers
|
{listLoading ? (
|
||||||
|
<span className="animate-pulse">Loading dealer data...</span>
|
||||||
|
) : (
|
||||||
|
<>Showing {apiDealers.length} of {totalCount.toLocaleString()} dealers</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Dealers Table */}
|
{/* Dealers Table - Show loading, error, or table */}
|
||||||
<DealerTable dealers={paginatedDealers} onRowClick={handleRowClick} />
|
{listLoading ? (
|
||||||
|
<div className="bg-card rounded-lg border border-border overflow-hidden">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<div className="max-h-[600px] overflow-y-auto">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead className="bg-muted sticky top-0 z-20 shadow-sm">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-3 text-left text-sm font-medium text-foreground">State</th>
|
||||||
|
<th className="px-4 py-3 text-left text-sm font-medium text-foreground">District</th>
|
||||||
|
<th className="px-4 py-3 text-left text-sm font-medium text-foreground">Dealer Name</th>
|
||||||
|
<th className="px-4 py-3 text-left text-sm font-medium text-foreground">MFMS ID</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">Companies</th>
|
||||||
|
<th className="px-4 py-3 text-left text-sm font-medium text-foreground">Sales Rating</th>
|
||||||
|
<th className="px-4 py-3 text-left text-sm font-medium text-foreground">Buy Rating</th>
|
||||||
|
<th className="px-4 py-3 text-left text-sm font-medium text-foreground">Liquidity Cycle</th>
|
||||||
|
<th className="px-4 py-3 text-left text-sm font-medium text-foreground">Ack. Cycle</th>
|
||||||
|
<th className="px-4 py-3 text-left text-sm font-medium text-foreground">Current Stock</th>
|
||||||
|
<th className="px-4 py-3 text-left text-sm font-medium text-foreground">Aged Stock</th>
|
||||||
|
<th className="px-4 py-3 text-left text-sm font-medium text-foreground min-w-[200px]">Credit Score</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{[1, 2, 3, 4, 5].map((i) => (
|
||||||
|
<tr key={i} className="border-b border-border hover:bg-muted/50 transition-colors">
|
||||||
|
{Array.from({ length: 14 }).map((_, j) => (
|
||||||
|
<td key={j} className="px-4 py-3">
|
||||||
|
<div className="h-4 bg-muted rounded animate-pulse" style={{ width: `${Math.random() * 40 + 60}%` }}></div>
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : listError ? (
|
||||||
|
<div className="flex flex-col items-center justify-center p-10 text-destructive">
|
||||||
|
<p className="font-semibold">Error loading dealers</p>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">{listError}</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<DealerTable dealers={apiDealers} onRowClick={handleRowClick} />
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Pagination */}
|
{/* Pagination */}
|
||||||
<Pagination
|
<Pagination
|
||||||
|
|||||||
@ -1,6 +1,8 @@
|
|||||||
import { useParams, useNavigate } from "react-router-dom";
|
import { useParams, useNavigate } from "react-router-dom";
|
||||||
import { useEffect, useMemo, useCallback } from "react";
|
import { useEffect, useMemo, useCallback } from "react";
|
||||||
import { dealers, getScoreBreakdown, getCreditScoreColor } from "@/lib/mockData";
|
import { dealers, getScoreBreakdown, getCreditScoreColor, type Dealer, type ScoreParameter } from "@/lib/mockData";
|
||||||
|
import { dealerService } from "@/api/services/dealer.service";
|
||||||
|
import { useState } from "react";
|
||||||
import { useAuth } from "@/hooks/useAuth";
|
import { useAuth } from "@/hooks/useAuth";
|
||||||
import { LoadingSpinner } from "@/components/common/LoadingSpinner";
|
import { LoadingSpinner } from "@/components/common/LoadingSpinner";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@ -11,6 +13,7 @@ import {
|
|||||||
CreditScoreBreakdown,
|
CreditScoreBreakdown,
|
||||||
KeyInsights,
|
KeyInsights,
|
||||||
ActivityTimeline,
|
ActivityTimeline,
|
||||||
|
type Insight,
|
||||||
} from "@/components/dealer";
|
} from "@/components/dealer";
|
||||||
import {
|
import {
|
||||||
CreditScoreTrendChart,
|
CreditScoreTrendChart,
|
||||||
@ -23,13 +26,183 @@ const DealerProfile = () => {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { user, userRole, loading } = useAuth();
|
const { user, userRole, loading } = useAuth();
|
||||||
|
|
||||||
// Find dealer - memoized
|
// Fetch dealer from API
|
||||||
const dealer = useMemo(() => dealers.find((d) => d.id === id), [id]);
|
const [apiDealer, setApiDealer] = useState<Dealer | null>(null);
|
||||||
|
const [creditTrend, setCreditTrend] = useState<any[]>([]);
|
||||||
|
const [salesData, setSalesData] = useState<any[]>([]);
|
||||||
|
const [stockAgeData, setStockAgeData] = useState<any[]>([]);
|
||||||
|
const [creditScoreBreakdown, setCreditScoreBreakdown] = useState<ScoreParameter[]>([]);
|
||||||
|
const [keyInsights, setKeyInsights] = useState<Insight[]>([]);
|
||||||
|
const [fetchingDealer, setFetchingDealer] = useState(true);
|
||||||
|
|
||||||
// Memoized calculations
|
useEffect(() => {
|
||||||
|
const fetchDealer = async () => {
|
||||||
|
if (!id) {
|
||||||
|
setFetchingDealer(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setFetchingDealer(true);
|
||||||
|
try {
|
||||||
|
// Fetch both details and snapshot in parallel
|
||||||
|
// Fetch details, snapshot, trend, sales vs purchase, and stock age distribution in parallel
|
||||||
|
const [detailsRes, snapshotRes, trendRes, salesRes, stockRes, breakdownRes, insightsRes] = await Promise.all([
|
||||||
|
dealerService.getDealerDetails(id).catch(e => ({ success: false, data: null })),
|
||||||
|
dealerService.getDealerSnapshot(id).catch(e => ({ success: false, data: null })),
|
||||||
|
dealerService.getCreditScoreTrend(id).catch(e => ({ success: false, data: null })),
|
||||||
|
dealerService.getSalesVsPurchase(id).catch(e => ({ success: false, data: null })),
|
||||||
|
dealerService.getStockAgeDistribution(id).catch(e => ({ success: false, data: null })),
|
||||||
|
dealerService.getCreditScoreBreakdown(id).catch(e => ({ success: false, data: null })),
|
||||||
|
dealerService.getKeyInsights(id).catch(e => ({ success: false, data: null }))
|
||||||
|
]);
|
||||||
|
|
||||||
|
let mappedDealer: Dealer | null = null;
|
||||||
|
|
||||||
|
// Process Details Response
|
||||||
|
if (detailsRes.success && detailsRes.data) {
|
||||||
|
const d = detailsRes.data;
|
||||||
|
mappedDealer = {
|
||||||
|
id: String(d.id || d.dealer_id),
|
||||||
|
state: d.state || "",
|
||||||
|
district: d.district || "",
|
||||||
|
city: d.city || d.district || "",
|
||||||
|
dealerName: d.dealer_name || d.name || "Unknown Dealer",
|
||||||
|
mfmsId: d.mfms_id || "",
|
||||||
|
noOfProducts: d.no_of_products || d.products_count || 0,
|
||||||
|
noOfCompanies: d.no_of_companies || d.companies_count || 0,
|
||||||
|
salesRating: d.sales_rating || 0,
|
||||||
|
buyRating: d.buy_rating || 0,
|
||||||
|
avgLiquidityCycle: Math.round((d.avg_liquidity_cycle || 0) * 10) / 10,
|
||||||
|
avgAcknowledgmentCycle: Math.round((d.avg_acknowledgment_cycle || 0) * 10) / 10,
|
||||||
|
currentStock: d.current_stock || 0,
|
||||||
|
agedStock: d.aged_stock || 0,
|
||||||
|
creditScore: d.credit_score || 0,
|
||||||
|
dealerType: d.dealer_type || "Retailer",
|
||||||
|
totalSales6M: d.total_sales_6m || 0,
|
||||||
|
totalPurchase6M: d.total_purchase_6m || 0,
|
||||||
|
stockAge: d.stock_age || 0,
|
||||||
|
mobile: d.mobile_number || d.mobile,
|
||||||
|
aadhaar: d.aadhaar,
|
||||||
|
dealerLicense: d.dealer_license || d.license_number
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process Snapshot Response and merge/overwrite
|
||||||
|
if (snapshotRes.success && snapshotRes.data) {
|
||||||
|
const s = snapshotRes.data;
|
||||||
|
if (!mappedDealer) {
|
||||||
|
// If details failed but snapshot worked, create a basic shell
|
||||||
|
mappedDealer = {
|
||||||
|
id: id,
|
||||||
|
state: "", district: "", city: "", dealerName: "Dealer " + id, mfmsId: "",
|
||||||
|
mobile: "", aadhaar: "", dealerLicense: "",
|
||||||
|
salesRating: 0, buyRating: 0, creditScore: 0,
|
||||||
|
// Fill Snapshot fields
|
||||||
|
dealerType: s.type || "Retailer",
|
||||||
|
noOfCompanies: s.total_companies_associated || 0,
|
||||||
|
noOfProducts: s.active_products || 0,
|
||||||
|
totalSales6M: (s.total_sales_6m_rolling?.value ?? s.total_sales_6m_rolling) || 0,
|
||||||
|
totalPurchase6M: (s.total_purchase_6m_rolling?.value ?? s.total_purchase_6m_rolling) || 0,
|
||||||
|
avgLiquidityCycle: (s.avg_liquidity_cycle_3m_weighted?.value ?? s.avg_liquidity_cycle_3m_weighted) || 0,
|
||||||
|
avgAcknowledgmentCycle: (s.avg_acknowledgment_cycle_3m_weighted?.value ?? s.avg_acknowledgment_cycle_3m_weighted) || 0,
|
||||||
|
currentStock: (s.current_stock_quantity?.value ?? s.current_stock_quantity) || 0,
|
||||||
|
agedStock: (s.aged_stock_over_90_days?.value ?? s.aged_stock_over_90_days ?? s.aged_stock_90_days) || 0,
|
||||||
|
stockAge: (s.avg_stock_age?.value ?? s.avg_stock_age) || 0,
|
||||||
|
} as Dealer;
|
||||||
|
} else {
|
||||||
|
// Merge snapshot data
|
||||||
|
mappedDealer.dealerType = s.type || mappedDealer.dealerType;
|
||||||
|
mappedDealer.noOfCompanies = s.total_companies_associated ?? mappedDealer.noOfCompanies;
|
||||||
|
mappedDealer.noOfProducts = s.active_products ?? mappedDealer.noOfProducts;
|
||||||
|
mappedDealer.totalSales6M = (s.total_sales_6m_rolling?.value ?? s.total_sales_6m_rolling) ?? mappedDealer.totalSales6M;
|
||||||
|
mappedDealer.totalPurchase6M = (s.total_purchase_6m_rolling?.value ?? s.total_purchase_6m_rolling) ?? mappedDealer.totalPurchase6M;
|
||||||
|
mappedDealer.avgLiquidityCycle = (s.avg_liquidity_cycle_3m_weighted?.value ?? s.avg_liquidity_cycle_3m_weighted) ?? mappedDealer.avgLiquidityCycle;
|
||||||
|
mappedDealer.avgAcknowledgmentCycle = (s.avg_acknowledgment_cycle_3m_weighted?.value ?? s.avg_acknowledgment_cycle_3m_weighted) ?? mappedDealer.avgAcknowledgmentCycle;
|
||||||
|
mappedDealer.currentStock = (s.current_stock_quantity?.value ?? s.current_stock_quantity) ?? mappedDealer.currentStock;
|
||||||
|
mappedDealer.agedStock = (s.aged_stock_over_90_days?.value ?? s.aged_stock_over_90_days ?? s.aged_stock_90_days) ?? mappedDealer.agedStock;
|
||||||
|
mappedDealer.stockAge = (s.avg_stock_age?.value ?? s.avg_stock_age) ?? mappedDealer.stockAge;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// Process Trend Response
|
||||||
|
if (trendRes.success && trendRes.data?.data) {
|
||||||
|
setCreditTrend(trendRes.data.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process Sales vs Purchase Response
|
||||||
|
if (salesRes.success && salesRes.data?.data) {
|
||||||
|
setSalesData(salesRes.data.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process Stock Age Distribution Response
|
||||||
|
if (stockRes.success && stockRes.data?.distribution) {
|
||||||
|
setStockAgeData(stockRes.data.distribution);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process Credit Score Breakdown Response
|
||||||
|
if (breakdownRes.success && breakdownRes.data?.breakdown) {
|
||||||
|
const breakdownData = breakdownRes.data.breakdown;
|
||||||
|
const parameterNames: Record<string, string> = {
|
||||||
|
sales_performance: 'Sales Performance',
|
||||||
|
purchase_rating: 'Purchase Rating',
|
||||||
|
liquidity_cycle: 'Liquidity Cycle',
|
||||||
|
acknowledgment_cycle: 'Acknowledgment Cycle',
|
||||||
|
current_stock: 'Current Stock',
|
||||||
|
ageing: 'Ageing',
|
||||||
|
regional_risk_factor: 'Regional Risk Factor'
|
||||||
|
};
|
||||||
|
|
||||||
|
const mappedBreakdown: ScoreParameter[] = Object.entries(breakdownData).map(([key, value]: [string, any]) => ({
|
||||||
|
parameter: parameterNames[key] || key,
|
||||||
|
weight: value.weight || 0,
|
||||||
|
dealerScore: value.score || 0,
|
||||||
|
remarks: value.comment || ''
|
||||||
|
}));
|
||||||
|
|
||||||
|
setCreditScoreBreakdown(mappedBreakdown);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process Key Insights Response
|
||||||
|
if (insightsRes.success && insightsRes.data?.insights) {
|
||||||
|
const insights = insightsRes.data.insights.map((insight: any) => ({
|
||||||
|
title: insight.title || '',
|
||||||
|
description: insight.description || '',
|
||||||
|
type: insight.type === 'warning' ? 'warning' : 'success'
|
||||||
|
}));
|
||||||
|
setKeyInsights(insights);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mappedDealer) {
|
||||||
|
setApiDealer(mappedDealer);
|
||||||
|
} else {
|
||||||
|
// Fallback to mock data if BOTH fail
|
||||||
|
const mock = dealers.find((d) => d.id === id);
|
||||||
|
setApiDealer(mock || null);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to fetch dealer:", error);
|
||||||
|
// Fallback to mock data
|
||||||
|
const mock = dealers.find((d) => d.id === id);
|
||||||
|
setApiDealer(mock || null);
|
||||||
|
} finally {
|
||||||
|
setFetchingDealer(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchDealer();
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
const dealer = apiDealer;
|
||||||
|
const isLoading = loading || fetchingDealer;
|
||||||
|
|
||||||
|
// Memoized calculations - use API data if available, fallback to mock
|
||||||
const scoreBreakdown = useMemo(
|
const scoreBreakdown = useMemo(
|
||||||
() => dealer ? getScoreBreakdown(dealer.id, dealer.creditScore) : [],
|
() => creditScoreBreakdown.length > 0
|
||||||
[dealer]
|
? creditScoreBreakdown
|
||||||
|
: (dealer ? getScoreBreakdown(dealer.id, dealer.creditScore) : []),
|
||||||
|
[dealer, creditScoreBreakdown]
|
||||||
);
|
);
|
||||||
|
|
||||||
const creditColor = useMemo(
|
const creditColor = useMemo(
|
||||||
@ -87,7 +260,7 @@ const DealerProfile = () => {
|
|||||||
}, [user, loading, navigate]);
|
}, [user, loading, navigate]);
|
||||||
|
|
||||||
// Loading state
|
// Loading state
|
||||||
if (loading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-[#E8F5E9]/30 backdrop-blur-sm flex items-center justify-center font-poppins">
|
<div className="min-h-screen bg-[#E8F5E9]/30 backdrop-blur-sm flex items-center justify-center font-poppins">
|
||||||
<LoadingSpinner size="lg" label="Fetching Dealer Insights..." />
|
<LoadingSpinner size="lg" label="Fetching Dealer Insights..." />
|
||||||
@ -155,7 +328,7 @@ const DealerProfile = () => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Key Insights */}
|
{/* Key Insights */}
|
||||||
{!isBankCustomer && <KeyInsights />}
|
{!isBankCustomer && <KeyInsights insights={keyInsights} />}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Charts Section */}
|
{/* Charts Section */}
|
||||||
@ -166,7 +339,7 @@ const DealerProfile = () => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Credit Score Trend */}
|
{/* Credit Score Trend */}
|
||||||
<CreditScoreTrendChart creditScore={dealer.creditScore} compact={isBankCustomer} />
|
<CreditScoreTrendChart creditScore={dealer.creditScore} data={creditTrend} compact={isBankCustomer} />
|
||||||
|
|
||||||
{/* Key Insights - For bank customers */}
|
{/* Key Insights - For bank customers */}
|
||||||
{isBankCustomer && <KeyInsights compact />}
|
{isBankCustomer && <KeyInsights compact />}
|
||||||
@ -175,11 +348,12 @@ const DealerProfile = () => {
|
|||||||
<SalesPurchaseChart
|
<SalesPurchaseChart
|
||||||
totalSales6M={dealer.totalSales6M}
|
totalSales6M={dealer.totalSales6M}
|
||||||
totalPurchase6M={dealer.totalPurchase6M}
|
totalPurchase6M={dealer.totalPurchase6M}
|
||||||
|
data={salesData}
|
||||||
compact={isBankCustomer}
|
compact={isBankCustomer}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Stock Age Distribution */}
|
{/* Stock Age Distribution */}
|
||||||
{showStockAgeDistribution && <StockAgeChart />}
|
{showStockAgeDistribution && <StockAgeChart data={stockAgeData} />}
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -3,7 +3,7 @@ import { useNavigate } from "react-router-dom";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Sprout } from "lucide-react";
|
import { Sprout, Loader2 } from "lucide-react";
|
||||||
import loginHero from "@/assets/login-hero.jpg";
|
import loginHero from "@/assets/login-hero.jpg";
|
||||||
import { useToast } from "@/components/ui/use-toast";
|
import { useToast } from "@/components/ui/use-toast";
|
||||||
import { useAuth } from "@/hooks/useAuth";
|
import { useAuth } from "@/hooks/useAuth";
|
||||||
@ -132,10 +132,17 @@ const Login = () => {
|
|||||||
|
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="w-full h-11 sm:h-12 bg-[#16A34A] hover:bg-[#15803D] text-white text-base sm:text-lg font-medium"
|
className="w-full h-11 sm:h-12 bg-[#16A34A] hover:bg-[#15803D] text-white text-base sm:text-lg font-medium disabled:opacity-70 disabled:cursor-not-allowed"
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
>
|
>
|
||||||
{isLoading ? "Logging in..." : "Login"}
|
{isLoading ? (
|
||||||
|
<span className="flex items-center justify-center gap-2">
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
Logging in...
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
"Login"
|
||||||
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
|||||||
@ -1,18 +1,73 @@
|
|||||||
import { useParams, useNavigate } from "react-router-dom";
|
import { useParams, useNavigate } from "react-router-dom";
|
||||||
import { useEffect } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { ArrowLeft, Download } from "lucide-react";
|
import { ArrowLeft, Download } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card } from "@/components/ui/card";
|
import { Card } from "@/components/ui/card";
|
||||||
import { dealers, getMonthlyProductScores } from "@/lib/mockData";
|
import { dealerService } from "@/api/services/dealer.service";
|
||||||
import { useToast } from "@/components/ui/use-toast";
|
import { useToast } from "@/components/ui/use-toast";
|
||||||
import { useAuth } from "@/hooks/useAuth";
|
import { useAuth } from "@/hooks/useAuth";
|
||||||
|
import { LoadingSpinner } from "@/components/common/LoadingSpinner";
|
||||||
|
|
||||||
|
interface ProductScore {
|
||||||
|
month: string;
|
||||||
|
month_key: string;
|
||||||
|
score: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProductTrend {
|
||||||
|
product: string;
|
||||||
|
scores: ProductScore[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DealerData {
|
||||||
|
dealer_id: string;
|
||||||
|
dealer_name: string;
|
||||||
|
mfms_id: string;
|
||||||
|
location: string;
|
||||||
|
overall_credit_score: number;
|
||||||
|
period: string;
|
||||||
|
products: ProductTrend[];
|
||||||
|
}
|
||||||
|
|
||||||
const ScoreCard = () => {
|
const ScoreCard = () => {
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const { user, userRole, loading } = useAuth();
|
const { user, userRole, loading } = useAuth();
|
||||||
const dealer = dealers.find((d) => d.id === id);
|
|
||||||
|
const [dealerData, setDealerData] = useState<DealerData | null>(null);
|
||||||
|
const [fetchingData, setFetchingData] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Fetch data from API
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchData = async () => {
|
||||||
|
if (!id) {
|
||||||
|
setFetchingData(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setFetchingData(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await dealerService.getProductWiseScoreTrends(id);
|
||||||
|
|
||||||
|
if (response.success && response.data) {
|
||||||
|
setDealerData(response.data);
|
||||||
|
} else {
|
||||||
|
setError('Failed to fetch dealer score trends');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error fetching score trends:', err);
|
||||||
|
setError('Failed to load dealer data');
|
||||||
|
} finally {
|
||||||
|
setFetchingData(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchData();
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!loading && !user) {
|
if (!loading && !user) {
|
||||||
@ -29,20 +84,6 @@ const ScoreCard = () => {
|
|||||||
}
|
}
|
||||||
}, [user, userRole, loading, navigate, id, toast]);
|
}, [user, userRole, loading, navigate, id, toast]);
|
||||||
|
|
||||||
if (!dealer) {
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-background flex items-center justify-center">
|
|
||||||
<div className="text-center">
|
|
||||||
<h2 className="text-2xl font-bold mb-4">Dealer Not Found</h2>
|
|
||||||
<Button onClick={() => navigate("/")}>Back to Dashboard</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const monthlyScores = getMonthlyProductScores(dealer.id);
|
|
||||||
const months = monthlyScores[0]?.months || [];
|
|
||||||
|
|
||||||
const handleDownload = () => {
|
const handleDownload = () => {
|
||||||
toast({
|
toast({
|
||||||
title: "Download Started",
|
title: "Download Started",
|
||||||
@ -62,20 +103,30 @@ const ScoreCard = () => {
|
|||||||
return "text-[#EF4444]";
|
return "text-[#EF4444]";
|
||||||
};
|
};
|
||||||
|
|
||||||
const calculateProportionateScore = (productScore: number, overallScore: number) => {
|
// Loading state
|
||||||
// Adjust product score to be proportionate to overall credit score
|
if (loading || fetchingData) {
|
||||||
const proportion = overallScore / 1000;
|
|
||||||
return Math.round(productScore * proportion);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-background flex items-center justify-center">
|
<div className="min-h-screen bg-background flex items-center justify-center">
|
||||||
<div className="text-center">Loading...</div>
|
<LoadingSpinner size="lg" label="Loading Score Card..." />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Error or not found state
|
||||||
|
if (error || !dealerData) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-background flex items-center justify-center">
|
||||||
|
<div className="text-center">
|
||||||
|
<h2 className="text-2xl font-bold mb-4">{error || 'Dealer Not Found'}</h2>
|
||||||
|
<Button onClick={() => navigate("/")}>Back to Dashboard</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get months from first product's scores
|
||||||
|
const months = dealerData.products[0]?.scores?.map(s => s.month) || [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-background font-poppins">
|
<div className="min-h-screen bg-background font-poppins">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
@ -83,7 +134,7 @@ const ScoreCard = () => {
|
|||||||
<div className="max-w-7xl mx-auto">
|
<div className="max-w-7xl mx-auto">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<Button variant="ghost" size="icon" onClick={() => navigate(`/dealer/${dealer.id}`)} className="rounded-full">
|
<Button variant="ghost" size="icon" onClick={() => navigate(`/dealer/${id}`)} className="rounded-full">
|
||||||
<ArrowLeft className="h-5 w-5" />
|
<ArrowLeft className="h-5 w-5" />
|
||||||
</Button>
|
</Button>
|
||||||
<div>
|
<div>
|
||||||
@ -104,17 +155,17 @@ const ScoreCard = () => {
|
|||||||
<Card className="p-4 sm:p-6">
|
<Card className="p-4 sm:p-6">
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-xl sm:text-2xl font-bold text-foreground mb-1">{dealer.dealerName}</h2>
|
<h2 className="text-xl sm:text-2xl font-bold text-foreground mb-1">{dealerData.dealer_name}</h2>
|
||||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-xs sm:text-sm text-muted-foreground">
|
<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><strong>MFMS ID:</strong> {dealerData.mfms_id}</span>
|
||||||
<span className="hidden sm:inline">•</span>
|
<span className="hidden sm:inline">•</span>
|
||||||
<span><strong>Location:</strong> {dealer.city}, {dealer.state}</span>
|
<span><strong>Location:</strong> {dealerData.location}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-left sm:text-right">
|
<div className="text-left sm:text-right">
|
||||||
<p className="text-xs sm:text-sm text-muted-foreground mb-1">Overall Credit Score</p>
|
<p className="text-xs sm:text-sm text-muted-foreground mb-1">Overall Credit Score</p>
|
||||||
<p className={`text-3xl sm:text-4xl font-bold ${getScoreColorText(dealer.creditScore)}`}>
|
<p className={`text-3xl sm:text-4xl font-bold ${getScoreColorText(dealerData.overall_credit_score)}`}>
|
||||||
{dealer.creditScore}
|
{dealerData.overall_credit_score}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -146,21 +197,18 @@ const ScoreCard = () => {
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{monthlyScores.map((row: { product: string; m1: number; m2: number; m3: number; m4: number; m5: number }, idx: number) => (
|
{dealerData.products.map((product: ProductTrend, idx: number) => (
|
||||||
<tr key={idx} className="hover:bg-gray-50 transition-colors">
|
<tr key={idx} className="hover:bg-gray-50 transition-colors">
|
||||||
<td className="border border-gray-300 px-3 sm:px-6 py-3 sm:py-4 text-xs sm:text-sm font-medium text-foreground">
|
<td className="border border-gray-300 px-3 sm:px-6 py-3 sm:py-4 text-xs sm:text-sm font-medium text-foreground">
|
||||||
{row.product}
|
{product.product}
|
||||||
</td>
|
</td>
|
||||||
{[row.m1, row.m2, row.m3, row.m4, row.m5].map((score, scoreIdx) => {
|
{product.scores.map((scoreData: ProductScore, scoreIdx: number) => (
|
||||||
const cellScore = calculateProportionateScore(score, dealer.creditScore);
|
<td key={scoreIdx} className="border border-gray-300 px-3 sm:px-6 py-3 sm:py-4 text-center">
|
||||||
return (
|
<span className={`inline-block px-2 sm:px-3 py-1 rounded font-semibold text-[10px] sm:text-xs ${getScoreColor(scoreData.score)}`}>
|
||||||
<td key={scoreIdx} className="border border-gray-300 px-3 sm:px-6 py-3 sm:py-4 text-center">
|
{scoreData.score}
|
||||||
<span className={`inline-block px-2 sm:px-3 py-1 rounded font-semibold text-[10px] sm:text-xs ${getScoreColor(cellScore)}`}>
|
</span>
|
||||||
{cellScore}
|
</td>
|
||||||
</span>
|
))}
|
||||||
</td>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
@ -193,7 +241,7 @@ const ScoreCard = () => {
|
|||||||
{/* Card 4: Footer Note */}
|
{/* Card 4: Footer Note */}
|
||||||
<Card className="p-6">
|
<Card className="p-6">
|
||||||
<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. Product scores are proportionally adjusted to reflect the dealer's overall credit score.
|
<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>
|
||||||
</Card>
|
</Card>
|
||||||
</main>
|
</main>
|
||||||
@ -202,3 +250,4 @@ const ScoreCard = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default ScoreCard;
|
export default ScoreCard;
|
||||||
|
|
||||||
|
|||||||
@ -2,9 +2,12 @@ import { lazy, Suspense } from 'react';
|
|||||||
import { Routes, Route } from 'react-router-dom';
|
import { Routes, Route } from 'react-router-dom';
|
||||||
import { LoadingSpinner } from '@/components/common/LoadingSpinner';
|
import { LoadingSpinner } from '@/components/common/LoadingSpinner';
|
||||||
|
|
||||||
// Lazy load all page components for code splitting
|
// Import Dashboard directly (not lazy) for faster initial load
|
||||||
|
// Other pages can remain lazy loaded as they're less frequently accessed
|
||||||
|
import Dashboard from '@/pages/Dashboard';
|
||||||
|
|
||||||
|
// Lazy load other page components for code splitting
|
||||||
const Login = lazy(() => import('@/pages/Login'));
|
const Login = lazy(() => import('@/pages/Login'));
|
||||||
const Dashboard = lazy(() => import('@/pages/Dashboard'));
|
|
||||||
const DealerProfile = lazy(() => import('@/pages/DealerProfile'));
|
const DealerProfile = lazy(() => import('@/pages/DealerProfile'));
|
||||||
const ScoreCard = lazy(() => import('@/pages/ScoreCard'));
|
const ScoreCard = lazy(() => import('@/pages/ScoreCard'));
|
||||||
const NotFound = lazy(() => import('@/components/common/NotFound'));
|
const NotFound = lazy(() => import('@/components/common/NotFound'));
|
||||||
|
|||||||
@ -14,10 +14,10 @@ export default defineConfig({
|
|||||||
'@': path.resolve(__dirname, './src'),
|
'@': path.resolve(__dirname, './src'),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
server: {
|
/* server: {
|
||||||
proxy: {
|
proxy: {
|
||||||
'/api': {
|
'/api': {
|
||||||
target: 'http://192.168.1.12:8003 ',
|
target: 'http://localhost:8003 ',
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
secure: false,
|
secure: false,
|
||||||
configure: (proxy, _options) => {
|
configure: (proxy, _options) => {
|
||||||
@ -33,5 +33,5 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
}, */
|
||||||
})
|
})
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user