changes updated on integrations 19th jan

This commit is contained in:
Chandini 2026-01-19 20:24:17 +05:30
parent 883450eafd
commit 4da7f519e8
22 changed files with 1331 additions and 236 deletions

View File

@ -5,29 +5,47 @@ import axios from 'axios';
* In development, this relies on Vite's proxy (see vite.config.ts) to avoid CORS issues.
*/
const axiosInstance = axios.create({
baseURL: '/api', // This matches the proxy path in vite.config.ts
timeout: 10000,
baseURL: 'http://localhost:8003/api', // Direct backend URL (requires CORS on backend)
timeout: 30000, // Default timeout (30 seconds)
headers: {
'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.
axiosInstance.interceptors.request.use(
(config) => {
// You can get the token from localStorage or a store (e.g., Zustand)
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;
return addAuthToken(config);
},
(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;

View File

@ -9,8 +9,18 @@ export const API_ENDPOINTS = {
REFRESH_TOKEN: '/v1/auth/refresh-token',
},
DEALERS: {
LIST: '/dealers',
DETAIL: (id: string | number) => `/dealers/${id}`,
KPI: '/dealers/kpi',
LIST: '/v1/dealers/list',
DETAIL: (id: string | number) => `/v1/dealers/${id}`,
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}`,
},
};

View File

@ -1,5 +1,6 @@
export { default as axiosInstance } from './axiosInstance';
export * from './endpoints';
export * from './services/auth.service';
export * from './services/dealer.service';
export * from './types';
// Export other services as they are created

View 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;
}
},
};

View File

@ -3,6 +3,12 @@ export interface ApiResponse<T = any> {
message: string;
data: T;
timestamp: string;
pagination?: {
page: number;
limit: number;
total: number;
totalPages: number;
};
}
export interface UserData {
@ -29,3 +35,17 @@ export interface AppUser {
role: UserRole;
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;
}

View File

@ -1,5 +1,5 @@
import { memo, useState, useCallback, useMemo, useEffect } from "react";
import { Download, ChevronDown } from "lucide-react";
import { memo, useState, useCallback, useEffect, useMemo } from "react";
import { Download, ChevronDown, Loader2 } from "lucide-react";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
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 { Label } from "@/components/ui/label";
import { Card } from "@/components/ui/card";
import { indianStates, stateDistricts } from "@/lib/mockData";
import { useDebounce } from "@/hooks/useDebounce";
import { dealerService } from "@/api/services/dealer.service";
interface SearchFiltersProps {
onSearch: (query: string) => void;
onFilter: (filters: FilterState) => void;
onDownload: () => void;
isLoading?: boolean;
}
export interface FilterState {
@ -39,6 +40,7 @@ const StateCheckbox = memo(({
id={`state-${state}`}
checked={isChecked}
onCheckedChange={(checked) => onToggle(state, checked === true)}
className="!border-[#16a34a] !border-2 data-[state=checked]:!bg-[#16a34a] data-[state=checked]:!border-[#16a34a]"
/>
<label
htmlFor={`state-${state}`}
@ -65,6 +67,7 @@ const DistrictCheckbox = memo(({
id={`district-${district}`}
checked={isChecked}
onCheckedChange={(checked) => onToggle(district, checked === true)}
className="!border-[#16a34a] !border-2 data-[state=checked]:!bg-[#16a34a] data-[state=checked]:!border-[#16a34a]"
/>
<label
htmlFor={`district-${district}`}
@ -111,10 +114,15 @@ const CreditScoreRange = memo(({
));
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 [stateOpen, setStateOpen] = 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>({
states: [],
districts: [],
@ -136,6 +144,59 @@ export const SearchFilters = memo(({ onSearch, onFilter, onDownload }: SearchFil
onSearch(debouncedSearchQuery);
}, [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 newFilters = { ...filters, [key]: value };
setFilters(newFilters);
@ -150,6 +211,13 @@ export const SearchFilters = memo(({ onSearch, onFilter, onDownload }: SearchFil
const newFilters = {
...filters,
states: newStates,
// If we deselect a state, we should probably clear districts involved with it?
// But since we refetch districts based on new states, valid districts will be returned.
// We might want to filter out selected districts that are no longer in the available list.
// However user might want to keep them if they are still valid.
// For now, let's keep it simple and just update states. The district list will update visually.
// If we want to be strict, we can filter districts against the new availableDistricts list in the useEffect,
// but that requires another effect or logic.
districts: newStates.length === 0 ? [] : filters.districts,
};
@ -167,12 +235,6 @@ export const SearchFilters = memo(({ onSearch, onFilter, onDownload }: SearchFil
onFilter(newFilters);
}, [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) => {
handleFilterChange('dealerType', value);
}, [handleFilterChange]);
@ -188,13 +250,19 @@ export const SearchFilters = memo(({ onSearch, onFilter, onDownload }: SearchFil
return (
<div className="space-y-4">
{/* Search Bar Row */}
<div className="w-full">
<div className="w-full relative">
<Input
placeholder="Search by dealer name, city, state, district, credit score, mobile, aadhaar, or license..."
value={searchQuery}
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>
{/* 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">
<div className="p-2">
<div className="max-h-[300px] overflow-y-auto">
{indianStates.map((state) => (
{availableStates.map((state) => (
<StateCheckbox
key={state}
state={state}
@ -263,8 +331,9 @@ export const SearchFilters = memo(({ onSearch, onFilter, onDownload }: SearchFil
</SelectTrigger>
<SelectContent className="bg-popover z-50">
<SelectItem value="all">All Types</SelectItem>
<SelectItem value="Retailer">Retailer</SelectItem>
<SelectItem value="Wholesaler">Wholesaler</SelectItem>
<SelectItem value="retailer">Retailer</SelectItem>
<SelectItem value="distributor">Distributor</SelectItem>
<SelectItem value="wholesaler">Wholesaler</SelectItem>
</SelectContent>
</Select>
</div>

View File

@ -3,13 +3,18 @@ import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContai
import { Card } from '@/components/ui/card';
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 (
<Card className={`p-4 ${compact ? '' : 'h-[280px]'}`}>
@ -18,14 +23,14 @@ export const CreditScoreTrendChart = memo(({ creditScore, compact = false }: Cre
<LineChart data={creditTrend}>
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" />
<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 />
<Line
type="monotone"
dataKey="score"
stroke="#16A34A"
strokeWidth={2}
dot={{ fill: '#16A34A', r: 4 }}
<Line
type="monotone"
dataKey="score"
stroke="#16A34A"
strokeWidth={2}
dot={{ fill: '#16A34A', r: 4 }}
/>
</LineChart>
</ResponsiveContainer>

View File

@ -3,21 +3,24 @@ import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, Responsive
import { Card } from '@/components/ui/card';
import { getSalesPurchaseTrend } from '@/lib/mockData';
interface SalesPurchaseChartProps {
totalSales6M: number;
totalPurchase6M: number;
compact?: boolean;
}
export const SalesPurchaseChart = memo(({
totalSales6M,
export const SalesPurchaseChart = memo(({
totalSales6M,
totalPurchase6M,
compact = false
}: SalesPurchaseChartProps) => {
const salesPurchaseTrend = useMemo(
() => getSalesPurchaseTrend(totalSales6M, totalPurchase6M),
[totalSales6M, totalPurchase6M]
);
data,
compact = false
}: { totalSales6M: number; totalPurchase6M: number; data?: any[]; compact?: boolean }) => {
const salesPurchaseTrend = useMemo(() => {
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 (
<Card className={`p-4 ${compact ? '' : 'h-[280px]'}`}>
@ -29,8 +32,8 @@ export const SalesPurchaseChart = memo(({
<YAxis stroke="#6b7280" style={{ fontSize: '12px' }} />
<Tooltip />
<Legend wrapperStyle={{ fontSize: '12px' }} />
<Bar dataKey="sales" fill="#16A34A" name="sales" />
<Bar dataKey="purchase" fill="#3B82F6" name="purchase" />
<Bar dataKey="sales" fill="#16A34A" name="sales" />
</BarChart>
</ResponsiveContainer>
</Card>

View File

@ -5,44 +5,61 @@ import { getStockAgeDistribution } from '@/lib/mockData';
const COLORS = ['#4ade80', '#60a5fa', '#fbbf24', '#f87171', '#a78bfa', '#38bdf8', '#fb923c'];
export const StockAgeChart = memo(() => {
const stockAgeData = useMemo(() => getStockAgeDistribution(), []);
export const StockAgeChart = memo(({ data }: { data?: any[] }) => {
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 (
<Card className="p-4 sm:h-[280px]">
<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">
<ResponsiveContainer width="100%" height={180}>
<PieChart>
<Pie
data={stockAgeData}
cx="50%"
cy="50%"
labelLine={false}
outerRadius={70}
innerRadius={35}
fill="#8884d8"
dataKey="value"
>
{stockAgeData.map((_, index) => (
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
))}
</Pie>
<Tooltip />
</PieChart>
</ResponsiveContainer>
<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">
{stockAgeData.map((entry, index) => (
<div key={index} className="flex items-center gap-1 text-xs">
<div
className="w-2 h-2 rounded-full flex-shrink-0"
style={{ backgroundColor: COLORS[index % COLORS.length] }}
/>
<span className="text-muted-foreground text-[10px] sm:text-xs">
{entry.range}: {entry.value}%
</span>
</div>
))}
<div className="flex flex-col sm:flex-row items-center gap-x-2 gap-y-4 h-full justify-center">
<div className="w-[140px] h-[140px] flex-shrink-0">
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={stockAgeData}
cx="50%"
cy="50%"
labelLine={false}
outerRadius={60}
innerRadius={30}
fill="#8884d8"
dataKey="value"
>
{stockAgeData.map((_, index) => (
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
))}
</Pie>
<Tooltip />
</PieChart>
</ResponsiveContainer>
</div>
<div className="flex-1 min-w-0 flex flex-col justify-center">
<div className="grid grid-cols-2 gap-x-2 gap-y-2">
{stockAgeData.map((entry, index) => (
<div key={index} className="flex items-center gap-1.5 min-w-0">
<div
className="w-2.5 h-2.5 rounded-sm flex-shrink-0"
style={{ backgroundColor: COLORS[index % COLORS.length] }}
/>
<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>
</Card>

View File

@ -15,8 +15,8 @@ export const DealerSnapshot = memo(({ dealer, title = "Dealer Snapshot" }: Deale
{ label: "Active Products", value: dealer.noOfProducts },
{ label: "Total Sales (6M Rolling)", value: `${dealer.totalSales6M} MT` },
{ label: "Total Purchase (6M Rolling)", value: `${dealer.totalPurchase6M} MT` },
{ label: "Avg. Liquidity Cycle (3M Weighted)", value: `${dealer.avgLiquidityCycle} Days` },
{ label: "Avg. Acknowledgment Cycle (3M Weighted)", value: `${dealer.avgAcknowledgmentCycle} 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 > 0 ? dealer.avgAcknowledgmentCycle.toFixed(1) : 0} Days` },
{ label: "Avg. Stock Age", value: `${dealer.stockAge} Days` },
{ label: "Aged Stock (>90 Days)", value: `${dealer.agedStock} MT` },
{ label: "Current Stock Quantity", value: `${dealer.currentStock} MT` },
@ -67,8 +67,8 @@ export const CompareBusinessSnapshot = memo(({ data }: CompareBusinessSnapshotPr
{ label: "Active Products", value: data.activeProducts },
{ label: "Total Sales (6M Rolling)", value: `${data.totalSales} MT` },
{ label: "Total Purchase (6M Rolling)", value: `${data.totalPurchase} MT` },
{ label: "Avg. Liquidity Cycle (3M Weighted)", value: `${data.avgLiquidityCycle} Days` },
{ label: "Avg. Acknowledgment Cycle (3M Weighted)", value: `${data.avgAcknowledgmentCycle} 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 > 0 ? data.avgAcknowledgmentCycle.toFixed(1) : 0} Days` },
{ label: "Avg. Stock Age", value: `${data.avgStockAge} Days` },
{ label: "Aged Stock (>90 Days)", value: `${data.agedStock} MT` },
{ label: "Current Stock Quantity", value: `${data.currentStock} MT` },

View File

@ -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.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.avgLiquidityCycle} Days</td>
<td className="px-4 py-3 text-sm text-center">{dealer.avgAcknowledgmentCycle} Days</td>
<td className="px-4 py-3 text-sm text-center">
{dealer.avgLiquidityCycle > 0 ? `${dealer.avgLiquidityCycle.toFixed(1)} Days` : '0 Days'}
</td>
<td className="px-4 py-3 text-sm text-center">
{dealer.avgAcknowledgmentCycle > 0 ? `${dealer.avgAcknowledgmentCycle.toFixed(1)} Days` : '0 Days'}
</td>
<td className="px-4 py-3 text-sm text-center">{dealer.currentStock} MT</td>
<td className="px-4 py-3 text-sm text-center">{dealer.agedStock} MT</td>
<td className="px-4 py-3 text-sm">

View File

@ -2,7 +2,7 @@ import { memo } from 'react';
import { Lightbulb } from 'lucide-react';
import { Card } from '@/components/ui/card';
interface Insight {
export interface Insight {
title: string;
description: string;
type: 'success' | 'warning';
@ -10,6 +10,7 @@ interface Insight {
interface KeyInsightsProps {
compact?: boolean;
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" },
];
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) {
return (
<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>
</div>
<div className="space-y-2">
{DEFAULT_INSIGHTS.map((insight, idx) => (
{displayInsights.map((insight, idx) => (
<div
key={idx}
className={`p-2 rounded-lg ${
insight.type === 'success'
? 'bg-success/10 border border-success/20'
className={`p-2 rounded-lg ${insight.type === 'success'
? 'bg-success/10 border border-success/20'
: 'bg-warning/10 border border-warning/20'
}`}
}`}
>
<h4 className={`font-semibold text-xs mb-0.5 ${
insight.type === 'success' ? 'text-success' : 'text-warning'
}`}>
<h4 className={`font-semibold text-xs mb-0.5 ${insight.type === 'success' ? 'text-success' : 'text-warning'
}`}>
{insight.title}
</h4>
<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>
</div>
<div className="space-y-4">
{DEFAULT_INSIGHTS.map((insight, idx) => (
{displayInsights.map((insight, idx) => (
<div
key={idx}
className={`p-4 rounded-lg ${
insight.type === 'success'
? 'bg-success/10 border border-success/20'
className={`p-4 rounded-lg ${insight.type === 'success'
? 'bg-success/10 border border-success/20'
: 'bg-warning/10 border border-warning/20'
}`}
}`}
>
<h3 className={`font-semibold mb-1 ${
insight.type === 'success' ? 'text-success' : 'text-warning'
}`}>
<h3 className={`font-semibold mb-1 ${insight.type === 'success' ? 'text-success' : 'text-warning'
}`}>
{insight.title}
</h3>
<p className="text-sm text-muted-foreground">{insight.description}</p>
@ -80,3 +80,4 @@ export const KeyInsights = memo(({ compact = false }: KeyInsightsProps) => {
});
KeyInsights.displayName = 'KeyInsights';

View File

@ -3,5 +3,6 @@ export { DealerTableRow } from './DealerTableRow';
export { DealerProfileHeader } from './DealerProfileHeader';
export { DealerSnapshot, CompareBusinessSnapshot } from './DealerSnapshot';
export { CreditScoreBreakdown } from './CreditScoreBreakdown';
export { KeyInsights } from './KeyInsights';
export { KeyInsights, type Insight } from './KeyInsights';
export { ActivityTimeline } from './ActivityTimeline';

View File

@ -40,10 +40,24 @@ const mapUserIdToRole = (userId: string): UserRole => {
};
export const AuthProvider = ({ children }: { children: ReactNode }) => {
const [user, setUser] = useState<AppUser | null>(null);
const [session, setSession] = useState<AuthSession | null>(null);
const [userRole, setUserRole] = useState<UserRole>(null);
const [loading, setLoading] = useState(true);
// OPTIMIZATION: Initialize from localStorage synchronously to avoid blocking render
const getInitialSession = (): AuthSession | null => {
try {
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();
// Helper to sync state from localStorage
@ -66,12 +80,11 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => {
setUser(null);
setUserRole(null);
}
setLoading(false);
}, []);
useEffect(() => {
// Initial sync
syncStateFromStorage();
// Initial sync (already done synchronously, but keep for consistency)
// This is mainly for storage event listeners
// Listen for storage changes (multi-tab support)
const handleStorageChange = (e: StorageEvent) => {

View 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
View 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 };
}

View File

@ -7,29 +7,53 @@ import { DashboardHeader } from "@/components/layout/DashboardHeader";
import { DealerTable } from "@/components/dealer/DealerTable";
import { Pagination } from "@/components/common/Pagination";
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 { useAuth } from "@/hooks/useAuth";
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 navigate = useNavigate();
const { toast } = useToast();
const { user, signOut, loading } = useAuth();
// Custom hooks for filtering and KPIs
// Custom hooks for filtering and KPIs (State Management only)
const {
searchQuery,
setSearchQuery,
filters,
setFilters,
currentPage,
setCurrentPage,
filteredDealers,
paginatedDealers,
totalPages,
} = useDealerFilters({ dealers, itemsPerPage: 100 });
} = useDealerFilters({ dealers: [], itemsPerPage: ITEMS_PER_PAGE });
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
useEffect(() => {
@ -61,15 +85,8 @@ const Dashboard = () => {
});
}, [toast]);
// Loading state
if (loading) {
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>
);
}
// Show UI immediately even if auth is loading (will redirect if not authenticated)
// This provides better UX - user sees the page structure immediately
return (
<div className="min-h-screen bg-background font-poppins">
<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>
</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">
<KPICard
title="Total Dealers"
value="2,60,000"
icon={Users}
trend="Active in system"
/>
<KPICard
title="Avg Credit Score"
value={kpis.avgCreditScore}
icon={TrendingUp}
trend="↑ 2.3% from last month"
trendColor="success"
/>
<KPICard
title="High-Risk Dealers"
value={`${kpis.highRiskPercentage}%`}
icon={AlertTriangle}
trend="Score below 500"
trendColor="danger"
/>
<KPICard
title="Avg Liquidity Cycle"
value={`${kpis.avgLiquidityCycle} Days`}
icon={Clock}
trend="↓ 1.8 days improved"
trendColor="success"
/>
{overviewLoading || loading ? (
// Show loading skeleton for all cards
<>
{[1, 2, 3, 4].map((i) => (
<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>
<div className="h-8 w-32 bg-muted rounded mb-2"></div>
<div className="h-3 w-40 bg-muted rounded"></div>
</div>
))}
</>
) : (
<>
<KPICard
title="Total Dealers"
value={displayKPIs.totalDealers?.toLocaleString() || "0"}
icon={Users}
trend="Active in system"
/>
<KPICard
title="Avg Credit Score"
value={displayKPIs.avgCreditScore}
icon={TrendingUp}
trend="↑ 2.3% from last month"
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>
{/* Search & Filters */}
@ -121,16 +153,66 @@ const Dashboard = () => {
onSearch={setSearchQuery}
onFilter={setFilters}
onDownload={handleDownload}
isLoading={listLoading}
/>
</div>
{/* Results Summary */}
<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>
{/* Dealers Table */}
<DealerTable dealers={paginatedDealers} onRowClick={handleRowClick} />
{/* Dealers Table - Show loading, error, or table */}
{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

View File

@ -1,6 +1,8 @@
import { useParams, useNavigate } from "react-router-dom";
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 { LoadingSpinner } from "@/components/common/LoadingSpinner";
import { Button } from "@/components/ui/button";
@ -11,6 +13,7 @@ import {
CreditScoreBreakdown,
KeyInsights,
ActivityTimeline,
type Insight,
} from "@/components/dealer";
import {
CreditScoreTrendChart,
@ -23,13 +26,183 @@ const DealerProfile = () => {
const navigate = useNavigate();
const { user, userRole, loading } = useAuth();
// Find dealer - memoized
const dealer = useMemo(() => dealers.find((d) => d.id === id), [id]);
// Fetch dealer from API
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(
() => dealer ? getScoreBreakdown(dealer.id, dealer.creditScore) : [],
[dealer]
() => creditScoreBreakdown.length > 0
? creditScoreBreakdown
: (dealer ? getScoreBreakdown(dealer.id, dealer.creditScore) : []),
[dealer, creditScoreBreakdown]
);
const creditColor = useMemo(
@ -87,7 +260,7 @@ const DealerProfile = () => {
}, [user, loading, navigate]);
// Loading state
if (loading) {
if (isLoading) {
return (
<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..." />
@ -155,7 +328,7 @@ const DealerProfile = () => {
)}
{/* Key Insights */}
{!isBankCustomer && <KeyInsights />}
{!isBankCustomer && <KeyInsights insights={keyInsights} />}
</div>
{/* Charts Section */}
@ -166,7 +339,7 @@ const DealerProfile = () => {
)}
{/* Credit Score Trend */}
<CreditScoreTrendChart creditScore={dealer.creditScore} compact={isBankCustomer} />
<CreditScoreTrendChart creditScore={dealer.creditScore} data={creditTrend} compact={isBankCustomer} />
{/* Key Insights - For bank customers */}
{isBankCustomer && <KeyInsights compact />}
@ -175,11 +348,12 @@ const DealerProfile = () => {
<SalesPurchaseChart
totalSales6M={dealer.totalSales6M}
totalPurchase6M={dealer.totalPurchase6M}
data={salesData}
compact={isBankCustomer}
/>
{/* Stock Age Distribution */}
{showStockAgeDistribution && <StockAgeChart />}
{showStockAgeDistribution && <StockAgeChart data={stockAgeData} />}
</div>
</main>
</div>

View File

@ -3,7 +3,7 @@ import { useNavigate } from "react-router-dom";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
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 { useToast } from "@/components/ui/use-toast";
import { useAuth } from "@/hooks/useAuth";
@ -132,10 +132,17 @@ const Login = () => {
<Button
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}
>
{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>
</form>

View File

@ -1,18 +1,73 @@
import { useParams, useNavigate } from "react-router-dom";
import { useEffect } from "react";
import { useEffect, useState } from "react";
import { ArrowLeft, Download } from "lucide-react";
import { Button } from "@/components/ui/button";
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 { 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 { id } = useParams();
const navigate = useNavigate();
const { toast } = useToast();
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(() => {
if (!loading && !user) {
@ -29,20 +84,6 @@ const ScoreCard = () => {
}
}, [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 = () => {
toast({
title: "Download Started",
@ -62,20 +103,30 @@ const ScoreCard = () => {
return "text-[#EF4444]";
};
const calculateProportionateScore = (productScore: number, overallScore: number) => {
// Adjust product score to be proportionate to overall credit score
const proportion = overallScore / 1000;
return Math.round(productScore * proportion);
};
if (loading) {
// Loading state
if (loading || fetchingData) {
return (
<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>
);
}
// 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 (
<div className="min-h-screen bg-background font-poppins">
{/* Header */}
@ -83,7 +134,7 @@ const ScoreCard = () => {
<div className="max-w-7xl mx-auto">
<div className="flex items-center justify-between">
<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" />
</Button>
<div>
@ -104,17 +155,17 @@ const ScoreCard = () => {
<Card className="p-4 sm:p-6">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<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">
<span><strong>MFMS ID:</strong> {dealer.mfmsId}</span>
<span><strong>MFMS ID:</strong> {dealerData.mfms_id}</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 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-3xl sm:text-4xl font-bold ${getScoreColorText(dealer.creditScore)}`}>
{dealer.creditScore}
<p className={`text-3xl sm:text-4xl font-bold ${getScoreColorText(dealerData.overall_credit_score)}`}>
{dealerData.overall_credit_score}
</p>
</div>
</div>
@ -146,21 +197,18 @@ const ScoreCard = () => {
</tr>
</thead>
<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">
<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>
{[row.m1, row.m2, row.m3, row.m4, row.m5].map((score, scoreIdx) => {
const cellScore = calculateProportionateScore(score, dealer.creditScore);
return (
<td key={scoreIdx} className="border border-gray-300 px-3 sm:px-6 py-3 sm:py-4 text-center">
<span className={`inline-block px-2 sm:px-3 py-1 rounded font-semibold text-[10px] sm:text-xs ${getScoreColor(cellScore)}`}>
{cellScore}
</span>
</td>
);
})}
{product.scores.map((scoreData: ProductScore, scoreIdx: number) => (
<td key={scoreIdx} className="border border-gray-300 px-3 sm:px-6 py-3 sm:py-4 text-center">
<span className={`inline-block px-2 sm:px-3 py-1 rounded font-semibold text-[10px] sm:text-xs ${getScoreColor(scoreData.score)}`}>
{scoreData.score}
</span>
</td>
))}
</tr>
))}
</tbody>
@ -193,7 +241,7 @@ const ScoreCard = () => {
{/* Card 4: Footer Note */}
<Card className="p-6">
<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>
</Card>
</main>
@ -202,3 +250,4 @@ const ScoreCard = () => {
};
export default ScoreCard;

View File

@ -2,9 +2,12 @@ import { lazy, Suspense } from 'react';
import { Routes, Route } from 'react-router-dom';
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 Dashboard = lazy(() => import('@/pages/Dashboard'));
const DealerProfile = lazy(() => import('@/pages/DealerProfile'));
const ScoreCard = lazy(() => import('@/pages/ScoreCard'));
const NotFound = lazy(() => import('@/components/common/NotFound'));

View File

@ -14,10 +14,10 @@ export default defineConfig({
'@': path.resolve(__dirname, './src'),
},
},
server: {
/* server: {
proxy: {
'/api': {
target: 'http://192.168.1.12:8003 ',
target: 'http://localhost:8003 ',
changeOrigin: true,
secure: false,
configure: (proxy, _options) => {
@ -33,5 +33,5 @@ export default defineConfig({
},
},
},
},
}, */
})