Smooth analytics page reloads
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 34s
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 34s
This commit is contained in:
@@ -155,6 +155,23 @@ const initDB = async () => {
|
|||||||
|
|
||||||
await pool.query(`CREATE INDEX IF NOT EXISTS idx_stock_campaign_queue_status ON stock_campaign_queue (status);`);
|
await pool.query(`CREATE INDEX IF NOT EXISTS idx_stock_campaign_queue_status ON stock_campaign_queue (status);`);
|
||||||
await pool.query(`CREATE INDEX IF NOT EXISTS idx_orders_cliente_fone ON orders (cliente_fone);`);
|
await pool.query(`CREATE INDEX IF NOT EXISTS idx_orders_cliente_fone ON orders (cliente_fone);`);
|
||||||
|
await pool.query(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_orders_normalized_cliente_nome
|
||||||
|
ON orders ((NULLIF(LOWER(TRIM(regexp_replace(COALESCE(cliente_nome, ''), '\\s+', ' ', 'g'))), '')));
|
||||||
|
`).catch(err => {
|
||||||
|
console.error('Notice: Could not create normalized client name index:', err.message);
|
||||||
|
});
|
||||||
|
await pool.query(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_orders_normalized_cliente_nome_phone_date
|
||||||
|
ON orders (
|
||||||
|
(NULLIF(LOWER(TRIM(regexp_replace(COALESCE(cliente_nome, ''), '\\s+', ' ', 'g'))), '')),
|
||||||
|
data_pedido_date DESC,
|
||||||
|
id DESC
|
||||||
|
)
|
||||||
|
WHERE NULLIF(cliente_fone, '') IS NOT NULL;
|
||||||
|
`).catch(err => {
|
||||||
|
console.error('Notice: Could not create normalized client phone lookup index:', err.message);
|
||||||
|
});
|
||||||
await pool.query(`
|
await pool.query(`
|
||||||
CREATE INDEX IF NOT EXISTS idx_orders_customer_key_date
|
CREATE INDEX IF NOT EXISTS idx_orders_customer_key_date
|
||||||
ON orders (
|
ON orders (
|
||||||
|
|||||||
@@ -17,31 +17,28 @@ const PRODUCT_NAME_SQL = `
|
|||||||
const NORMALIZED_CUSTOMER_NAME_SQL = "NULLIF(LOWER(TRIM(regexp_replace(COALESCE(cliente_nome, ''), '\\s+', ' ', 'g'))), '')";
|
const NORMALIZED_CUSTOMER_NAME_SQL = "NULLIF(LOWER(TRIM(regexp_replace(COALESCE(cliente_nome, ''), '\\s+', ' ', 'g'))), '')";
|
||||||
// Phone-less historical rows must follow the later known phone for the same client name.
|
// Phone-less historical rows must follow the later known phone for the same client name.
|
||||||
const CUSTOMER_IDENTITY_CTE = `
|
const CUSTOMER_IDENTITY_CTE = `
|
||||||
WITH order_identity AS (
|
WITH customer_phone_by_name AS (
|
||||||
SELECT
|
SELECT
|
||||||
orders.*,
|
${NORMALIZED_CUSTOMER_NAME_SQL} as normalized_customer_name,
|
||||||
${NORMALIZED_CUSTOMER_NAME_SQL} as normalized_customer_name
|
|
||||||
FROM orders
|
|
||||||
),
|
|
||||||
customer_phone_by_name AS (
|
|
||||||
SELECT
|
|
||||||
normalized_customer_name,
|
|
||||||
(ARRAY_AGG(NULLIF(cliente_fone, '') ORDER BY data_pedido_date DESC NULLS LAST, id DESC)
|
(ARRAY_AGG(NULLIF(cliente_fone, '') ORDER BY data_pedido_date DESC NULLS LAST, id DESC)
|
||||||
FILTER (WHERE NULLIF(cliente_fone, '') IS NOT NULL))[1] as canonical_phone
|
)[1] as canonical_phone
|
||||||
FROM order_identity
|
FROM orders
|
||||||
WHERE normalized_customer_name IS NOT NULL
|
WHERE NULLIF(cliente_fone, '') IS NOT NULL
|
||||||
|
AND ${NORMALIZED_CUSTOMER_NAME_SQL} IS NOT NULL
|
||||||
GROUP BY normalized_customer_name
|
GROUP BY normalized_customer_name
|
||||||
),
|
),
|
||||||
identity_orders AS (
|
identity_orders AS (
|
||||||
SELECT
|
SELECT
|
||||||
order_identity.*,
|
orders.*,
|
||||||
|
${NORMALIZED_CUSTOMER_NAME_SQL} as normalized_customer_name,
|
||||||
COALESCE(
|
COALESCE(
|
||||||
NULLIF(order_identity.cliente_fone, ''),
|
NULLIF(orders.cliente_fone, ''),
|
||||||
customer_phone_by_name.canonical_phone,
|
customer_phone_by_name.canonical_phone,
|
||||||
'name:' || COALESCE(NULLIF(order_identity.cliente_nome, ''), 'Cliente Desconhecido')
|
'name:' || COALESCE(NULLIF(orders.cliente_nome, ''), 'Cliente Desconhecido')
|
||||||
) as customer_key
|
) as customer_key
|
||||||
FROM order_identity
|
FROM orders
|
||||||
LEFT JOIN customer_phone_by_name USING (normalized_customer_name)
|
LEFT JOIN customer_phone_by_name
|
||||||
|
ON customer_phone_by_name.normalized_customer_name = ${NORMALIZED_CUSTOMER_NAME_SQL}
|
||||||
)
|
)
|
||||||
`;
|
`;
|
||||||
const CUSTOMER_KEY_SQL = 'customer_key';
|
const CUSTOMER_KEY_SQL = 'customer_key';
|
||||||
|
|||||||
@@ -601,10 +601,10 @@ test('getClientAnalytics groups phone-less history through the customer identity
|
|||||||
assert.equal(clients.length, 1);
|
assert.equal(clients.length, 1);
|
||||||
assert.equal(clients[0].customerKey, '(16) 99999-9999');
|
assert.equal(clients[0].customerKey, '(16) 99999-9999');
|
||||||
assert.equal(clients[0].phone, '(16) 99999-9999');
|
assert.equal(clients[0].phone, '(16) 99999-9999');
|
||||||
assert.match(calls[0].sql, /WITH order_identity AS/);
|
|
||||||
assert.match(calls[0].sql, /customer_phone_by_name AS/);
|
assert.match(calls[0].sql, /customer_phone_by_name AS/);
|
||||||
assert.match(calls[0].sql, /ARRAY_AGG\(NULLIF\(cliente_fone, ''\) ORDER BY data_pedido_date DESC NULLS LAST, id DESC\)/);
|
assert.match(calls[0].sql, /ARRAY_AGG\(NULLIF\(cliente_fone, ''\) ORDER BY data_pedido_date DESC NULLS LAST, id DESC\)/);
|
||||||
assert.match(calls[0].sql, /NULLIF\(order_identity\.cliente_fone, ''\),\s+customer_phone_by_name\.canonical_phone/s);
|
assert.match(calls[0].sql, /WHERE NULLIF\(cliente_fone, ''\) IS NOT NULL/);
|
||||||
|
assert.match(calls[0].sql, /NULLIF\(orders\.cliente_fone, ''\),\s+customer_phone_by_name\.canonical_phone/s);
|
||||||
assert.match(calls[0].sql, /FROM identity_orders/);
|
assert.match(calls[0].sql, /FROM identity_orders/);
|
||||||
assert.match(calls[0].sql, /GROUP BY customer_key/);
|
assert.match(calls[0].sql, /GROUP BY customer_key/);
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -2,6 +2,57 @@ import type { AuthUser, CampaignPreview, CampaignProcessSummary, CampaignQueueSu
|
|||||||
import { formatDateParam } from './dateRanges';
|
import { formatDateParam } from './dateRanges';
|
||||||
|
|
||||||
const API_URL = import.meta.env.VITE_API_URL || '/api';
|
const API_URL = import.meta.env.VITE_API_URL || '/api';
|
||||||
|
const ANALYTICS_CACHE_TTL_MS = 5 * 60 * 1000;
|
||||||
|
const IN_FLIGHT_CACHE_TTL_MS = 15 * 1000;
|
||||||
|
|
||||||
|
type CacheOptions = {
|
||||||
|
force?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ApiCacheEntry<T> = {
|
||||||
|
expiresAt: number;
|
||||||
|
data?: T;
|
||||||
|
promise?: Promise<T>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const analyticsCache = new Map<string, ApiCacheEntry<unknown>>();
|
||||||
|
|
||||||
|
const getCachedAnalyticsValue = <T>(key: string): T | undefined => {
|
||||||
|
const entry = analyticsCache.get(key) as ApiCacheEntry<T> | undefined;
|
||||||
|
if (!entry || entry.data === undefined || entry.expiresAt <= Date.now()) return undefined;
|
||||||
|
return entry.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getCachedAnalytics = async <T>(key: string, loader: () => Promise<T>, options: CacheOptions = {}): Promise<T> => {
|
||||||
|
const now = Date.now();
|
||||||
|
const cached = analyticsCache.get(key) as ApiCacheEntry<T> | undefined;
|
||||||
|
|
||||||
|
if (!options.force) {
|
||||||
|
if (cached?.data !== undefined && cached.expiresAt > now) return cached.data;
|
||||||
|
if (cached?.promise && cached.expiresAt > now) return cached.promise;
|
||||||
|
}
|
||||||
|
|
||||||
|
const promise = loader();
|
||||||
|
analyticsCache.set(key, { expiresAt: now + IN_FLIGHT_CACHE_TTL_MS, promise });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await promise;
|
||||||
|
if (data === null) {
|
||||||
|
analyticsCache.delete(key);
|
||||||
|
} else {
|
||||||
|
analyticsCache.set(key, { data, expiresAt: Date.now() + ANALYTICS_CACHE_TTL_MS });
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
} catch (error) {
|
||||||
|
analyticsCache.delete(key);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildDateRangeParams = (dateRange: DateRange) => new URLSearchParams({
|
||||||
|
start: formatDateParam(dateRange.start),
|
||||||
|
end: formatDateParam(dateRange.end)
|
||||||
|
});
|
||||||
|
|
||||||
export const login = async (email: string, password: string): Promise<boolean> => {
|
export const login = async (email: string, password: string): Promise<boolean> => {
|
||||||
try {
|
try {
|
||||||
@@ -109,49 +160,46 @@ const authFetch = async (path: string, options: RequestInit = {}): Promise<Respo
|
|||||||
return response;
|
return response;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const fetchDashboardAnalytics = async (dateRange: DateRange): Promise<DashboardAnalytics | null> => {
|
export const fetchDashboardAnalytics = async (dateRange: DateRange, options?: CacheOptions): Promise<DashboardAnalytics | null> => {
|
||||||
|
const path = `/analytics/dashboard?${buildDateRangeParams(dateRange).toString()}`;
|
||||||
|
return getCachedAnalytics(path, async () => {
|
||||||
try {
|
try {
|
||||||
const params = new URLSearchParams({
|
const response = await authFetch(path);
|
||||||
start: formatDateParam(dateRange.start),
|
|
||||||
end: formatDateParam(dateRange.end)
|
|
||||||
});
|
|
||||||
const response = await authFetch(`/analytics/dashboard?${params.toString()}`);
|
|
||||||
if (!response.ok) return null;
|
if (!response.ok) return null;
|
||||||
return await response.json();
|
return await response.json();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Fetch dashboard analytics failed', error);
|
console.error('Fetch dashboard analytics failed', error);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
}, options);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const fetchProductAnalytics = async (dateRange: DateRange): Promise<ProductAnalyticsItem[]> => {
|
export const fetchProductAnalytics = async (dateRange: DateRange): Promise<ProductAnalyticsItem[]> => {
|
||||||
|
const path = `/analytics/products?${buildDateRangeParams(dateRange).toString()}`;
|
||||||
|
return getCachedAnalytics(path, async () => {
|
||||||
try {
|
try {
|
||||||
const params = new URLSearchParams({
|
const response = await authFetch(path);
|
||||||
start: formatDateParam(dateRange.start),
|
|
||||||
end: formatDateParam(dateRange.end)
|
|
||||||
});
|
|
||||||
const response = await authFetch(`/analytics/products?${params.toString()}`);
|
|
||||||
if (!response.ok) return [];
|
if (!response.ok) return [];
|
||||||
return await response.json();
|
return await response.json();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Fetch product analytics failed', error);
|
console.error('Fetch product analytics failed', error);
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
export const fetchProductDetailsAnalytics = async (productId: string, dateRange: DateRange): Promise<ProductDetailsAnalytics | null> => {
|
export const fetchProductDetailsAnalytics = async (productId: string, dateRange: DateRange): Promise<ProductDetailsAnalytics | null> => {
|
||||||
|
const path = `/analytics/products/${encodeURIComponent(productId)}/details?${buildDateRangeParams(dateRange).toString()}`;
|
||||||
|
return getCachedAnalytics(path, async () => {
|
||||||
try {
|
try {
|
||||||
const params = new URLSearchParams({
|
const response = await authFetch(path);
|
||||||
start: formatDateParam(dateRange.start),
|
|
||||||
end: formatDateParam(dateRange.end)
|
|
||||||
});
|
|
||||||
const response = await authFetch(`/analytics/products/${encodeURIComponent(productId)}/details?${params.toString()}`);
|
|
||||||
if (!response.ok) return null;
|
if (!response.ok) return null;
|
||||||
return await response.json();
|
return await response.json();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Fetch product details analytics failed', error);
|
console.error('Fetch product details analytics failed', error);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const appendClientMetadataFilterParams = (params: URLSearchParams, filters?: Partial<ClientMetadataFilters>) => {
|
const appendClientMetadataFilterParams = (params: URLSearchParams, filters?: Partial<ClientMetadataFilters>) => {
|
||||||
@@ -162,65 +210,87 @@ const appendClientMetadataFilterParams = (params: URLSearchParams, filters?: Par
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const fetchClientFilterOptions = async (dateRange: DateRange): Promise<ClientFilterOptions> => {
|
export const fetchClientFilterOptions = async (dateRange: DateRange): Promise<ClientFilterOptions> => {
|
||||||
|
const path = `/analytics/clients/filters?${buildDateRangeParams(dateRange).toString()}`;
|
||||||
|
return getCachedAnalytics(path, async () => {
|
||||||
try {
|
try {
|
||||||
const params = new URLSearchParams({
|
const response = await authFetch(path);
|
||||||
start: formatDateParam(dateRange.start),
|
|
||||||
end: formatDateParam(dateRange.end)
|
|
||||||
});
|
|
||||||
const response = await authFetch(`/analytics/clients/filters?${params.toString()}`);
|
|
||||||
if (!response.ok) return { marketplaces: [], salesChannels: [], sellers: [] };
|
if (!response.ok) return { marketplaces: [], salesChannels: [], sellers: [] };
|
||||||
return await response.json();
|
return await response.json();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Fetch client filter options failed', error);
|
console.error('Fetch client filter options failed', error);
|
||||||
return { marketplaces: [], salesChannels: [], sellers: [] };
|
return { marketplaces: [], salesChannels: [], sellers: [] };
|
||||||
}
|
}
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
export const fetchRfmAnalytics = async (dateRange: DateRange, filters?: Partial<ClientMetadataFilters>): Promise<RfmAnalytics | null> => {
|
const buildRfmAnalyticsPath = (dateRange: DateRange, filters?: Partial<ClientMetadataFilters>) => {
|
||||||
try {
|
const params = buildDateRangeParams(dateRange);
|
||||||
const params = new URLSearchParams({
|
|
||||||
start: formatDateParam(dateRange.start),
|
|
||||||
end: formatDateParam(dateRange.end)
|
|
||||||
});
|
|
||||||
appendClientMetadataFilterParams(params, filters);
|
appendClientMetadataFilterParams(params, filters);
|
||||||
const response = await authFetch(`/analytics/rfm?${params.toString()}`);
|
return `/analytics/rfm?${params.toString()}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getCachedRfmAnalytics = (dateRange: DateRange, filters?: Partial<ClientMetadataFilters>): RfmAnalytics | undefined => {
|
||||||
|
return getCachedAnalyticsValue<RfmAnalytics>(buildRfmAnalyticsPath(dateRange, filters));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const fetchRfmAnalytics = async (dateRange: DateRange, filters?: Partial<ClientMetadataFilters>, options?: CacheOptions): Promise<RfmAnalytics | null> => {
|
||||||
|
const path = buildRfmAnalyticsPath(dateRange, filters);
|
||||||
|
return getCachedAnalytics(path, async () => {
|
||||||
|
try {
|
||||||
|
const response = await authFetch(path);
|
||||||
if (!response.ok) return null;
|
if (!response.ok) return null;
|
||||||
return await response.json();
|
return await response.json();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Fetch RFM analytics failed', error);
|
console.error('Fetch RFM analytics failed', error);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
}, options);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const fetchClientAnalytics = async (dateRange: DateRange, filters?: Partial<ClientMetadataFilters>): Promise<ClientAnalyticsItem[]> => {
|
const buildClientAnalyticsPath = (dateRange: DateRange, filters?: Partial<ClientMetadataFilters>) => {
|
||||||
try {
|
const params = buildDateRangeParams(dateRange);
|
||||||
const params = new URLSearchParams({
|
|
||||||
start: formatDateParam(dateRange.start),
|
|
||||||
end: formatDateParam(dateRange.end)
|
|
||||||
});
|
|
||||||
appendClientMetadataFilterParams(params, filters);
|
appendClientMetadataFilterParams(params, filters);
|
||||||
const response = await authFetch(`/analytics/clients?${params.toString()}`);
|
return `/analytics/clients?${params.toString()}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getCachedClientAnalytics = (dateRange: DateRange, filters?: Partial<ClientMetadataFilters>): ClientAnalyticsItem[] | undefined => {
|
||||||
|
return getCachedAnalyticsValue<ClientAnalyticsItem[]>(buildClientAnalyticsPath(dateRange, filters));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getCachedClientFilterOptions = (dateRange: DateRange): ClientFilterOptions | undefined => {
|
||||||
|
return getCachedAnalyticsValue<ClientFilterOptions>(`/analytics/clients/filters?${buildDateRangeParams(dateRange).toString()}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const fetchClientAnalytics = async (dateRange: DateRange, filters?: Partial<ClientMetadataFilters>, options?: CacheOptions): Promise<ClientAnalyticsItem[]> => {
|
||||||
|
const path = buildClientAnalyticsPath(dateRange, filters);
|
||||||
|
return getCachedAnalytics(path, async () => {
|
||||||
|
try {
|
||||||
|
const response = await authFetch(path);
|
||||||
if (!response.ok) return [];
|
if (!response.ok) return [];
|
||||||
return await response.json();
|
return await response.json();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Fetch client analytics failed', error);
|
console.error('Fetch client analytics failed', error);
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
}, options);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const fetchClientDetailsAnalytics = async (clientToken: string, dateRange: DateRange): Promise<ClientDetailsAnalytics | null> => {
|
export const fetchClientDetailsAnalytics = async (clientToken: string, dateRange: DateRange): Promise<ClientDetailsAnalytics | null> => {
|
||||||
|
const path = `/analytics/clients/${encodeURIComponent(clientToken)}/details?${buildDateRangeParams(dateRange).toString()}`;
|
||||||
|
return getCachedAnalytics(path, async () => {
|
||||||
try {
|
try {
|
||||||
const params = new URLSearchParams({
|
const response = await authFetch(path);
|
||||||
start: formatDateParam(dateRange.start),
|
|
||||||
end: formatDateParam(dateRange.end)
|
|
||||||
});
|
|
||||||
const response = await authFetch(`/analytics/clients/${encodeURIComponent(clientToken)}/details?${params.toString()}`);
|
|
||||||
if (!response.ok) return null;
|
if (!response.ok) return null;
|
||||||
return await response.json();
|
return await response.json();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Fetch client details analytics failed', error);
|
console.error('Fetch client details analytics failed', error);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const clearAnalyticsCache = () => {
|
||||||
|
analyticsCache.clear();
|
||||||
};
|
};
|
||||||
|
|
||||||
export const fetchCampaigns = async (): Promise<CampaignQueueSummary | null> => {
|
export const fetchCampaigns = async (): Promise<CampaignQueueSummary | null> => {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useEffect, useMemo, useRef, useState } from 'react';
|
|||||||
import { Link, useOutletContext } from 'react-router-dom';
|
import { Link, useOutletContext } from 'react-router-dom';
|
||||||
import { Search, ChevronRight, Filter, ChevronLeft, X } from 'lucide-react';
|
import { Search, ChevronRight, Filter, ChevronLeft, X } from 'lucide-react';
|
||||||
import type { ClientAnalyticsItem, ClientFilterOptions, ClientMetadataFilters, DateRange, RfmAnalytics, RfmClient } from '../types';
|
import type { ClientAnalyticsItem, ClientFilterOptions, ClientMetadataFilters, DateRange, RfmAnalytics, RfmClient } from '../types';
|
||||||
import { fetchClientAnalytics, fetchClientFilterOptions, fetchRfmAnalytics } from '../dataService';
|
import { fetchClientAnalytics, fetchClientFilterOptions, fetchRfmAnalytics, getCachedClientAnalytics, getCachedClientFilterOptions, getCachedRfmAnalytics } from '../dataService';
|
||||||
import { endOfLocalDay, formatDateParam, parseLocalDateInput, rangeForDay, rangeForLastDays, rangeForPreviousDay, startOfLocalDay } from '../dateRanges';
|
import { endOfLocalDay, formatDateParam, parseLocalDateInput, rangeForDay, rangeForLastDays, rangeForPreviousDay, startOfLocalDay } from '../dateRanges';
|
||||||
import type { ClientSortOption, ClientSummary } from '../analytics/clients';
|
import type { ClientSortOption, ClientSummary } from '../analytics/clients';
|
||||||
|
|
||||||
@@ -114,11 +114,14 @@ const Clients = () => {
|
|||||||
const [sortBy, setSortBy] = useState<ClientSortOption>('recent');
|
const [sortBy, setSortBy] = useState<ClientSortOption>('recent');
|
||||||
const [clientTypeFilter, setClientTypeFilter] = useState('all');
|
const [clientTypeFilter, setClientTypeFilter] = useState('all');
|
||||||
const [metadataFilters, setMetadataFilters] = useState<ClientMetadataFilters>(emptyClientFilters);
|
const [metadataFilters, setMetadataFilters] = useState<ClientMetadataFilters>(emptyClientFilters);
|
||||||
const [filterOptions, setFilterOptions] = useState<ClientFilterOptions>(emptyClientFilterOptions);
|
const initialFilterOptions = getCachedClientFilterOptions(dateRange);
|
||||||
|
const initialClientAnalytics = getCachedClientAnalytics(dateRange, emptyClientFilters);
|
||||||
|
const initialRfmAnalytics = getCachedRfmAnalytics(dateRange, emptyClientFilters);
|
||||||
|
const [filterOptions, setFilterOptions] = useState<ClientFilterOptions>(initialFilterOptions || emptyClientFilterOptions);
|
||||||
const [isFilterMenuOpen, setIsFilterMenuOpen] = useState(false);
|
const [isFilterMenuOpen, setIsFilterMenuOpen] = useState(false);
|
||||||
const filterMenuRef = useRef<HTMLDivElement>(null);
|
const filterMenuRef = useRef<HTMLDivElement>(null);
|
||||||
const [rfmAnalytics, setRfmAnalytics] = useState<RfmAnalytics | null>(null);
|
const [rfmAnalytics, setRfmAnalytics] = useState<RfmAnalytics | null>(initialRfmAnalytics || null);
|
||||||
const [clientAnalytics, setClientAnalytics] = useState<ClientAnalyticsItem[]>([]);
|
const [clientAnalytics, setClientAnalytics] = useState<ClientAnalyticsItem[]>(initialClientAnalytics || []);
|
||||||
|
|
||||||
// Pagination state
|
// Pagination state
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
@@ -128,15 +131,27 @@ const Clients = () => {
|
|||||||
let isMounted = true;
|
let isMounted = true;
|
||||||
|
|
||||||
const loadClients = async () => {
|
const loadClients = async () => {
|
||||||
const options = await fetchClientFilterOptions(dateRange);
|
const cachedOptions = getCachedClientFilterOptions(dateRange);
|
||||||
const clientsData = await fetchClientAnalytics(dateRange, metadataFilters);
|
const cachedClients = getCachedClientAnalytics(dateRange, metadataFilters);
|
||||||
|
const cachedRfm = getCachedRfmAnalytics(dateRange, metadataFilters);
|
||||||
|
|
||||||
|
if (isMounted) {
|
||||||
|
if (cachedOptions) setFilterOptions(cachedOptions);
|
||||||
|
if (cachedClients) setClientAnalytics(cachedClients);
|
||||||
|
setRfmAnalytics(cachedRfm || null);
|
||||||
|
}
|
||||||
|
|
||||||
|
const filterOptionsPromise = fetchClientFilterOptions(dateRange);
|
||||||
|
const clientsPromise = fetchClientAnalytics(dateRange, metadataFilters);
|
||||||
|
const rfmPromise = fetchRfmAnalytics(dateRange, metadataFilters);
|
||||||
|
const [options, clientsData] = await Promise.all([filterOptionsPromise, clientsPromise]);
|
||||||
|
|
||||||
if (isMounted) {
|
if (isMounted) {
|
||||||
setFilterOptions(options);
|
setFilterOptions(options);
|
||||||
setClientAnalytics(clientsData);
|
setClientAnalytics(clientsData);
|
||||||
setRfmAnalytics(null);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const rfmData = await fetchRfmAnalytics(dateRange, metadataFilters);
|
const rfmData = await rfmPromise;
|
||||||
if (isMounted) {
|
if (isMounted) {
|
||||||
setRfmAnalytics(rfmData);
|
setRfmAnalytics(rfmData);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,9 +55,9 @@ const Dashboard = () => {
|
|||||||
const [serverMetrics, setServerMetrics] = useState<DashboardAnalytics | null>(null);
|
const [serverMetrics, setServerMetrics] = useState<DashboardAnalytics | null>(null);
|
||||||
const [isMetricsLoading, setIsMetricsLoading] = useState(true);
|
const [isMetricsLoading, setIsMetricsLoading] = useState(true);
|
||||||
|
|
||||||
const loadDashboardMetrics = useCallback(async (range: DateRange) => {
|
const loadDashboardMetrics = useCallback(async (range: DateRange, options?: { force?: boolean }) => {
|
||||||
setIsMetricsLoading(true);
|
setIsMetricsLoading(true);
|
||||||
const metrics = await fetchDashboardAnalytics(range);
|
const metrics = await fetchDashboardAnalytics(range, options);
|
||||||
setServerMetrics(metrics);
|
setServerMetrics(metrics);
|
||||||
setIsMetricsLoading(false);
|
setIsMetricsLoading(false);
|
||||||
}, []);
|
}, []);
|
||||||
@@ -72,7 +72,7 @@ const Dashboard = () => {
|
|||||||
if (refreshInterval === 0) return;
|
if (refreshInterval === 0) return;
|
||||||
|
|
||||||
const intervalId = setInterval(() => {
|
const intervalId = setInterval(() => {
|
||||||
void loadDashboardMetrics(dateRange);
|
void loadDashboardMetrics(dateRange, { force: true });
|
||||||
}, refreshInterval);
|
}, refreshInterval);
|
||||||
|
|
||||||
return () => clearInterval(intervalId);
|
return () => clearInterval(intervalId);
|
||||||
@@ -84,7 +84,7 @@ const Dashboard = () => {
|
|||||||
}, [dateRange, ordersData, serverMetrics]);
|
}, [dateRange, ordersData, serverMetrics]);
|
||||||
|
|
||||||
const handleManualRefresh = () => {
|
const handleManualRefresh = () => {
|
||||||
void loadDashboardMetrics(dateRange);
|
void loadDashboardMetrics(dateRange, { force: true });
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
|
|||||||
import { Link, useOutletContext } from 'react-router-dom';
|
import { Link, useOutletContext } from 'react-router-dom';
|
||||||
import { ChevronLeft, ChevronRight, Download, Filter, Loader2, Search, Users } from 'lucide-react';
|
import { ChevronLeft, ChevronRight, Download, Filter, Loader2, Search, Users } from 'lucide-react';
|
||||||
import DateRangePicker from '../components/DateRangePicker';
|
import DateRangePicker from '../components/DateRangePicker';
|
||||||
import { exportToCSV, fetchRfmAnalytics } from '../dataService';
|
import { exportToCSV, fetchRfmAnalytics, getCachedRfmAnalytics } from '../dataService';
|
||||||
import type { DateRange, RfmAnalytics, RfmClient, RfmSegment } from '../types';
|
import type { DateRange, RfmAnalytics, RfmClient, RfmSegment } from '../types';
|
||||||
|
|
||||||
const emptyClients: RfmClient[] = [];
|
const emptyClients: RfmClient[] = [];
|
||||||
@@ -127,17 +127,25 @@ const Rfm = () => {
|
|||||||
setRefreshInterval: (interval: number) => void;
|
setRefreshInterval: (interval: number) => void;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const [analytics, setAnalytics] = useState<RfmAnalytics | null>(null);
|
const initialCachedAnalytics = getCachedRfmAnalytics(dateRange);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [analytics, setAnalytics] = useState<RfmAnalytics | null>(initialCachedAnalytics || null);
|
||||||
|
const [isLoading, setIsLoading] = useState(!initialCachedAnalytics);
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
const [segmentFilter, setSegmentFilter] = useState('all');
|
const [segmentFilter, setSegmentFilter] = useState('all');
|
||||||
const [selectedSegmentKey, setSelectedSegmentKey] = useState('');
|
const [selectedSegmentKey, setSelectedSegmentKey] = useState('');
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
const [itemsPerPage, setItemsPerPage] = useState(20);
|
const [itemsPerPage, setItemsPerPage] = useState(20);
|
||||||
|
|
||||||
const loadRfm = useCallback(async (range: DateRange) => {
|
const loadRfm = useCallback(async (range: DateRange, options?: { force?: boolean }) => {
|
||||||
|
const cachedAnalytics = options?.force ? undefined : getCachedRfmAnalytics(range);
|
||||||
|
if (cachedAnalytics) {
|
||||||
|
setAnalytics(cachedAnalytics);
|
||||||
|
setIsLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
const data = await fetchRfmAnalytics(range);
|
const data = await fetchRfmAnalytics(range, undefined, options);
|
||||||
setAnalytics(data);
|
setAnalytics(data);
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}, []);
|
}, []);
|
||||||
@@ -152,7 +160,7 @@ const Rfm = () => {
|
|||||||
if (refreshInterval === 0) return;
|
if (refreshInterval === 0) return;
|
||||||
|
|
||||||
const intervalId = setInterval(() => {
|
const intervalId = setInterval(() => {
|
||||||
void loadRfm(dateRange);
|
void loadRfm(dateRange, { force: true });
|
||||||
}, refreshInterval);
|
}, refreshInterval);
|
||||||
|
|
||||||
return () => clearInterval(intervalId);
|
return () => clearInterval(intervalId);
|
||||||
@@ -276,7 +284,7 @@ const Rfm = () => {
|
|||||||
onChange={setDateRange}
|
onChange={setDateRange}
|
||||||
refreshInterval={refreshInterval}
|
refreshInterval={refreshInterval}
|
||||||
setRefreshInterval={setRefreshInterval}
|
setRefreshInterval={setRefreshInterval}
|
||||||
onManualRefresh={() => void loadRfm(dateRange)}
|
onManualRefresh={() => void loadRfm(dateRange, { force: true })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user