Compare commits
7 Commits
7fb2507f53
...
f88ae2d491
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f88ae2d491 | ||
|
|
4b3d65587e | ||
|
|
bb69e79af6 | ||
|
|
28af45c315 | ||
|
|
19a29956d2 | ||
|
|
ab4d7a3bd7 | ||
|
|
de49dd50d9 |
@@ -109,6 +109,128 @@ const authFetch = async (path: string, options: RequestInit = {}): Promise<Respo
|
|||||||
return response;
|
return response;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const rfmSegmentLabels: Record<string, string> = {
|
||||||
|
champions: 'Champions',
|
||||||
|
potential_loyalists: 'Potenciais Leais',
|
||||||
|
new_customers: 'Novos Clientes',
|
||||||
|
loyal_customers: 'Clientes Leais',
|
||||||
|
need_attention: 'Precisam de Atenção',
|
||||||
|
about_to_sleep: 'Quase Dormindo',
|
||||||
|
at_risk: 'Em Risco',
|
||||||
|
hibernating: 'Hibernando',
|
||||||
|
lost: 'Perdidos'
|
||||||
|
};
|
||||||
|
|
||||||
|
const rfmSegmentByScore: Record<string, string> = {
|
||||||
|
'3-3': 'champions',
|
||||||
|
'3-2': 'potential_loyalists',
|
||||||
|
'3-1': 'new_customers',
|
||||||
|
'2-3': 'loyal_customers',
|
||||||
|
'2-2': 'need_attention',
|
||||||
|
'2-1': 'about_to_sleep',
|
||||||
|
'1-3': 'at_risk',
|
||||||
|
'1-2': 'hibernating',
|
||||||
|
'1-1': 'lost'
|
||||||
|
};
|
||||||
|
|
||||||
|
const scoreTertile = (value: number, values: number[], higherIsBetter = true): 1 | 2 | 3 => {
|
||||||
|
const numericValues = values.filter(Number.isFinite);
|
||||||
|
if (!numericValues.length) return 1;
|
||||||
|
if (numericValues.length === 1) return 3;
|
||||||
|
|
||||||
|
const min = Math.min(...numericValues);
|
||||||
|
const max = Math.max(...numericValues);
|
||||||
|
if (min === max) return higherIsBetter ? 2 : 3;
|
||||||
|
|
||||||
|
const sorted = [...numericValues].sort((a, b) => higherIsBetter ? a - b : b - a);
|
||||||
|
const index = sorted.findIndex(candidate => candidate === value);
|
||||||
|
const percentile = index / (sorted.length - 1);
|
||||||
|
|
||||||
|
return Math.min(3, Math.max(1, Math.floor(percentile * 3) + 1)) as 1 | 2 | 3;
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchClientAnalyticsForRange = async (dateRange: DateRange): Promise<ClientAnalyticsItem[]> => {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
start: formatDateParam(dateRange.start),
|
||||||
|
end: formatDateParam(dateRange.end)
|
||||||
|
});
|
||||||
|
const response = await authFetch(`/analytics/clients?${params.toString()}`);
|
||||||
|
if (!response.ok) return [];
|
||||||
|
return await response.json();
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildFallbackRfmAnalytics = async (dateRange: DateRange): Promise<RfmAnalytics | null> => {
|
||||||
|
let clientRows: ClientAnalyticsItem[];
|
||||||
|
try {
|
||||||
|
clientRows = await fetchClientAnalyticsForRange(dateRange);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Fetch fallback RFM analytics failed', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!clientRows.length) return null;
|
||||||
|
|
||||||
|
const rangeEndTime = dateRange.end.getTime();
|
||||||
|
const recencyValues = clientRows.map(client => {
|
||||||
|
const lastPurchaseTime = client.lastPurchaseDate ? new Date(client.lastPurchaseDate).getTime() : rangeEndTime;
|
||||||
|
return Math.max(0, Math.floor((rangeEndTime - lastPurchaseTime) / 86400000));
|
||||||
|
});
|
||||||
|
const frequencyValues = clientRows.map(client => client.orderCount);
|
||||||
|
const monetaryValues = clientRows.map(client => client.totalSpent);
|
||||||
|
|
||||||
|
const clients = clientRows.map((client, index) => {
|
||||||
|
const recencyScore = scoreTertile(recencyValues[index], recencyValues, false);
|
||||||
|
const frequencyScore = scoreTertile(client.orderCount, frequencyValues);
|
||||||
|
const monetaryScore = scoreTertile(client.totalSpent, monetaryValues);
|
||||||
|
const valueScore = Math.min(3, Math.max(1, Math.round((frequencyScore + monetaryScore) / 2))) as 1 | 2 | 3;
|
||||||
|
const segmentKey = rfmSegmentByScore[`${recencyScore}-${valueScore}`] || 'lost';
|
||||||
|
|
||||||
|
return {
|
||||||
|
customerKey: client.customerKey,
|
||||||
|
name: client.name,
|
||||||
|
phone: client.phone,
|
||||||
|
monetary: client.totalSpent,
|
||||||
|
frequency: client.orderCount,
|
||||||
|
quantityPurchased: client.quantityPurchased,
|
||||||
|
lastPurchaseDate: client.lastPurchaseDate,
|
||||||
|
recencyDays: recencyValues[index],
|
||||||
|
recencyScore,
|
||||||
|
frequencyScore,
|
||||||
|
monetaryScore,
|
||||||
|
valueScore,
|
||||||
|
rfmScore: `${recencyScore}${frequencyScore}${monetaryScore}`,
|
||||||
|
segmentKey,
|
||||||
|
segmentLabel: rfmSegmentLabels[segmentKey] || 'Perdidos'
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const segments = Object.entries(rfmSegmentLabels).map(([key, label]) => {
|
||||||
|
const segmentClients = clients.filter(client => client.segmentKey === key);
|
||||||
|
const totalRevenue = segmentClients.reduce((sum, client) => sum + client.monetary, 0);
|
||||||
|
|
||||||
|
return {
|
||||||
|
key,
|
||||||
|
label,
|
||||||
|
count: segmentClients.length,
|
||||||
|
totalRevenue,
|
||||||
|
averageRevenue: segmentClients.length ? totalRevenue / segmentClients.length : 0
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
range: {
|
||||||
|
start: formatDateParam(dateRange.start),
|
||||||
|
end: formatDateParam(dateRange.end)
|
||||||
|
},
|
||||||
|
clients,
|
||||||
|
segments,
|
||||||
|
matrix: {
|
||||||
|
recencyScores: [3, 2, 1],
|
||||||
|
valueScores: [1, 2, 3]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
export const fetchDashboardAnalytics = async (dateRange: DateRange): Promise<DashboardAnalytics | null> => {
|
export const fetchDashboardAnalytics = async (dateRange: DateRange): Promise<DashboardAnalytics | null> => {
|
||||||
try {
|
try {
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
@@ -131,23 +253,17 @@ export const fetchRfmAnalytics = async (dateRange: DateRange): Promise<RfmAnalyt
|
|||||||
end: formatDateParam(dateRange.end)
|
end: formatDateParam(dateRange.end)
|
||||||
});
|
});
|
||||||
const response = await authFetch(`/analytics/rfm?${params.toString()}`);
|
const response = await authFetch(`/analytics/rfm?${params.toString()}`);
|
||||||
if (!response.ok) return null;
|
if (!response.ok) return await buildFallbackRfmAnalytics(dateRange);
|
||||||
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 await buildFallbackRfmAnalytics(dateRange);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const fetchClientAnalytics = async (dateRange: DateRange): Promise<ClientAnalyticsItem[]> => {
|
export const fetchClientAnalytics = async (dateRange: DateRange): Promise<ClientAnalyticsItem[]> => {
|
||||||
try {
|
try {
|
||||||
const params = new URLSearchParams({
|
return await fetchClientAnalyticsForRange(dateRange);
|
||||||
start: formatDateParam(dateRange.start),
|
|
||||||
end: formatDateParam(dateRange.end)
|
|
||||||
});
|
|
||||||
const response = await authFetch(`/analytics/clients?${params.toString()}`);
|
|
||||||
if (!response.ok) return [];
|
|
||||||
return await response.json();
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Fetch client analytics failed', error);
|
console.error('Fetch client analytics failed', error);
|
||||||
return [];
|
return [];
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import DateRangePicker from '../components/DateRangePicker';
|
|||||||
import type { ClientSortOption, ClientSummary } from '../analytics/clients';
|
import type { ClientSortOption, ClientSummary } from '../analytics/clients';
|
||||||
|
|
||||||
const clientTypeStyles: Record<string, string> = {
|
const clientTypeStyles: Record<string, string> = {
|
||||||
|
'Sem análise': 'border-zinc-500/30 bg-zinc-500/15 text-zinc-300',
|
||||||
'Campeão': 'border-emerald-500/30 bg-emerald-500/15 text-emerald-300',
|
'Campeão': 'border-emerald-500/30 bg-emerald-500/15 text-emerald-300',
|
||||||
'Potencial Leal': 'border-sky-500/30 bg-sky-500/15 text-sky-300',
|
'Potencial Leal': 'border-sky-500/30 bg-sky-500/15 text-sky-300',
|
||||||
'Novo Cliente': 'border-cyan-500/30 bg-cyan-500/15 text-cyan-300',
|
'Novo Cliente': 'border-cyan-500/30 bg-cyan-500/15 text-cyan-300',
|
||||||
@@ -19,6 +20,7 @@ const clientTypeStyles: Record<string, string> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const clientTypes = [
|
const clientTypes = [
|
||||||
|
'Sem análise',
|
||||||
'Campeão',
|
'Campeão',
|
||||||
'Potencial Leal',
|
'Potencial Leal',
|
||||||
'Novo Cliente',
|
'Novo Cliente',
|
||||||
@@ -81,12 +83,13 @@ const Clients = () => {
|
|||||||
let isMounted = true;
|
let isMounted = true;
|
||||||
|
|
||||||
const loadClients = async () => {
|
const loadClients = async () => {
|
||||||
const [clientsData, rfmData] = await Promise.all([
|
const clientsData = await fetchClientAnalytics(dateRange);
|
||||||
fetchClientAnalytics(dateRange),
|
|
||||||
fetchRfmAnalytics(dateRange)
|
|
||||||
]);
|
|
||||||
if (isMounted) {
|
if (isMounted) {
|
||||||
setClientAnalytics(clientsData);
|
setClientAnalytics(clientsData);
|
||||||
|
}
|
||||||
|
|
||||||
|
const rfmData = await fetchRfmAnalytics(dateRange);
|
||||||
|
if (isMounted) {
|
||||||
setRfmAnalytics(rfmData);
|
setRfmAnalytics(rfmData);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -111,7 +114,7 @@ const Clients = () => {
|
|||||||
totalItems: client.quantityPurchased,
|
totalItems: client.quantityPurchased,
|
||||||
orderCount: client.orderCount,
|
orderCount: client.orderCount,
|
||||||
lastPurchase: client.lastPurchaseDate ? new Date(client.lastPurchaseDate).getTime() : 0,
|
lastPurchase: client.lastPurchaseDate ? new Date(client.lastPurchaseDate).getTime() : 0,
|
||||||
clientType: rfmClient ? (backendSegmentToClientType[rfmClient.segmentKey] || rfmClient.segmentLabel) : 'Perdido',
|
clientType: rfmClient ? (backendSegmentToClientType[rfmClient.segmentKey] || rfmClient.segmentLabel) : 'Sem análise',
|
||||||
rfmScore: rfmClient?.rfmScore || '000',
|
rfmScore: rfmClient?.rfmScore || '000',
|
||||||
rfmPriority: rfmClient ? getRfmPriority(rfmClient) : 0
|
rfmPriority: rfmClient ? getRfmPriority(rfmClient) : 0
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user