Add RFM segmentation analytics
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 3m12s
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 3m12s
This commit is contained in:
@@ -3,7 +3,8 @@ const { verifyToken } = require('../auth');
|
|||||||
const {
|
const {
|
||||||
getClientAnalytics,
|
getClientAnalytics,
|
||||||
getDashboardAnalytics,
|
getDashboardAnalytics,
|
||||||
getProductAnalytics
|
getProductAnalytics,
|
||||||
|
getRfmAnalytics
|
||||||
} = require('../services/analyticsService');
|
} = require('../services/analyticsService');
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
@@ -40,4 +41,13 @@ router.get('/analytics/clients', verifyToken, async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
router.get('/analytics/rfm', verifyToken, async (req, res) => {
|
||||||
|
try {
|
||||||
|
res.json(await getRfmAnalytics(getRange(req.query)));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching RFM analytics:', error);
|
||||||
|
res.status(500).json({ error: 'Internal Server Error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|||||||
@@ -55,6 +55,52 @@ const buildDateFilter = ({ start, end } = {}) => {
|
|||||||
|
|
||||||
const toNumber = (value) => Number(value || 0);
|
const toNumber = (value) => Number(value || 0);
|
||||||
|
|
||||||
|
const RFM_SEGMENTS = {
|
||||||
|
'3-3': { key: 'champions', label: 'Champions' },
|
||||||
|
'3-2': { key: 'potential_loyalists', label: 'Potenciais Leais' },
|
||||||
|
'3-1': { key: 'new_customers', label: 'Novos Clientes' },
|
||||||
|
'2-3': { key: 'loyal_customers', label: 'Clientes Leais' },
|
||||||
|
'2-2': { key: 'need_attention', label: 'Precisam de Atenção' },
|
||||||
|
'2-1': { key: 'about_to_sleep', label: 'Quase Dormindo' },
|
||||||
|
'1-3': { key: 'at_risk', label: 'Em Risco' },
|
||||||
|
'1-2': { key: 'hibernating', label: 'Hibernando' },
|
||||||
|
'1-1': { key: 'lost', label: 'Perdidos' }
|
||||||
|
};
|
||||||
|
|
||||||
|
const scoreTertile = (value, values, higherIsBetter = true) => {
|
||||||
|
const numericValues = values.map(toNumber).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 2;
|
||||||
|
|
||||||
|
const sorted = [...numericValues].sort((a, b) => higherIsBetter ? a - b : b - a);
|
||||||
|
const index = sorted.findIndex(candidate => candidate === toNumber(value));
|
||||||
|
const percentile = index / (sorted.length - 1);
|
||||||
|
|
||||||
|
return Math.min(3, Math.max(1, Math.floor(percentile * 3) + 1));
|
||||||
|
};
|
||||||
|
|
||||||
|
const getRfmSegment = (recencyScore, valueScore) => {
|
||||||
|
return RFM_SEGMENTS[`${recencyScore}-${valueScore}`] || RFM_SEGMENTS['1-1'];
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildRfmSegments = (clients) => {
|
||||||
|
return Object.values(RFM_SEGMENTS).map(segment => {
|
||||||
|
const segmentClients = clients.filter(client => client.segmentKey === segment.key);
|
||||||
|
const totalRevenue = segmentClients.reduce((sum, client) => sum + client.monetary, 0);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...segment,
|
||||||
|
count: segmentClients.length,
|
||||||
|
totalRevenue,
|
||||||
|
averageRevenue: segmentClients.length ? totalRevenue / segmentClients.length : 0
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const getDashboardAnalytics = async (range = {}) => {
|
const getDashboardAnalytics = async (range = {}) => {
|
||||||
const { params, whereClause } = buildDateFilter(range);
|
const { params, whereClause } = buildDateFilter(range);
|
||||||
const [totalsResult, salesResult, revenueResult] = await Promise.all([
|
const [totalsResult, salesResult, revenueResult] = await Promise.all([
|
||||||
@@ -174,10 +220,85 @@ const getClientAnalytics = async (range = {}) => {
|
|||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getRfmAnalytics = async (range = {}) => {
|
||||||
|
const { params, whereClause } = buildDateFilter(range);
|
||||||
|
const result = await pool.query(`
|
||||||
|
SELECT
|
||||||
|
MAX(cliente_nome) as name,
|
||||||
|
cliente_fone as phone,
|
||||||
|
COALESCE(SUM(quantidade * valor_unitario), 0) as monetary,
|
||||||
|
COUNT(DISTINCT COALESCE(NULLIF(pedido_id, ''), data_pedido || '_' || valor_pedido::text))::int as frequency,
|
||||||
|
COALESCE(SUM(quantidade), 0) as quantity_purchased,
|
||||||
|
MAX(data_pedido_date) as last_purchase_date,
|
||||||
|
GREATEST((CURRENT_DATE - MAX(data_pedido_date))::int, 0) as recency_days
|
||||||
|
FROM orders
|
||||||
|
${whereClause}
|
||||||
|
AND cliente_fone IS NOT NULL
|
||||||
|
AND cliente_fone != ''
|
||||||
|
GROUP BY cliente_fone
|
||||||
|
ORDER BY monetary DESC
|
||||||
|
LIMIT 1000;
|
||||||
|
`, params);
|
||||||
|
|
||||||
|
const baseClients = result.rows.map(row => ({
|
||||||
|
name: row.name,
|
||||||
|
phone: row.phone,
|
||||||
|
monetary: toNumber(row.monetary),
|
||||||
|
frequency: toNumber(row.frequency),
|
||||||
|
quantityPurchased: toNumber(row.quantity_purchased),
|
||||||
|
lastPurchaseDate: row.last_purchase_date,
|
||||||
|
recencyDays: toNumber(row.recency_days)
|
||||||
|
}));
|
||||||
|
|
||||||
|
const recencyValues = baseClients.map(client => client.recencyDays);
|
||||||
|
const frequencyValues = baseClients.map(client => client.frequency);
|
||||||
|
const monetaryValues = baseClients.map(client => client.monetary);
|
||||||
|
|
||||||
|
const clients = baseClients.map(client => {
|
||||||
|
const recencyScore = scoreTertile(client.recencyDays, recencyValues, false);
|
||||||
|
const frequencyScore = scoreTertile(client.frequency, frequencyValues, true);
|
||||||
|
const monetaryScore = scoreTertile(client.monetary, monetaryValues, true);
|
||||||
|
const valueScore = Math.min(3, Math.max(1, Math.round((frequencyScore + monetaryScore) / 2)));
|
||||||
|
const segment = getRfmSegment(recencyScore, valueScore);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...client,
|
||||||
|
recencyScore,
|
||||||
|
frequencyScore,
|
||||||
|
monetaryScore,
|
||||||
|
valueScore,
|
||||||
|
rfmScore: `${recencyScore}${frequencyScore}${monetaryScore}`,
|
||||||
|
segmentKey: segment.key,
|
||||||
|
segmentLabel: segment.label
|
||||||
|
};
|
||||||
|
}).sort((a, b) => {
|
||||||
|
if (b.recencyScore !== a.recencyScore) return b.recencyScore - a.recencyScore;
|
||||||
|
if (b.valueScore !== a.valueScore) return b.valueScore - a.valueScore;
|
||||||
|
return b.monetary - a.monetary;
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
range: {
|
||||||
|
start: normalizeDateParam(range.start),
|
||||||
|
end: normalizeDateParam(range.end)
|
||||||
|
},
|
||||||
|
clients,
|
||||||
|
segments: buildRfmSegments(clients),
|
||||||
|
matrix: {
|
||||||
|
recencyScores: [3, 2, 1],
|
||||||
|
valueScores: [1, 2, 3]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
buildDateFilter,
|
buildDateFilter,
|
||||||
|
buildRfmSegments,
|
||||||
|
getRfmAnalytics,
|
||||||
|
getRfmSegment,
|
||||||
getClientAnalytics,
|
getClientAnalytics,
|
||||||
getDashboardAnalytics,
|
getDashboardAnalytics,
|
||||||
getProductAnalytics,
|
getProductAnalytics,
|
||||||
normalizeDateParam
|
normalizeDateParam,
|
||||||
|
scoreTertile
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
const assert = require('node:assert/strict');
|
const assert = require('node:assert/strict');
|
||||||
const test = require('node:test');
|
const test = require('node:test');
|
||||||
|
|
||||||
const { buildDateFilter, normalizeDateParam } = require('../services/analyticsService');
|
const {
|
||||||
|
buildRfmSegments,
|
||||||
|
buildDateFilter,
|
||||||
|
getRfmSegment,
|
||||||
|
normalizeDateParam,
|
||||||
|
scoreTertile
|
||||||
|
} = require('../services/analyticsService');
|
||||||
|
|
||||||
test('normalizeDateParam accepts strict ISO dates', () => {
|
test('normalizeDateParam accepts strict ISO dates', () => {
|
||||||
assert.equal(normalizeDateParam('2026-05-28'), '2026-05-28');
|
assert.equal(normalizeDateParam('2026-05-28'), '2026-05-28');
|
||||||
@@ -32,3 +38,42 @@ test('buildDateFilter ignores invalid bounds', () => {
|
|||||||
'WHERE data_pedido_date IS NOT NULL AND data_pedido_date <= $1::date'
|
'WHERE data_pedido_date IS NOT NULL AND data_pedido_date <= $1::date'
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('scoreTertile scores higher values higher by default', () => {
|
||||||
|
const values = [10, 20, 30, 40, 50];
|
||||||
|
|
||||||
|
assert.equal(scoreTertile(10, values), 1);
|
||||||
|
assert.equal(scoreTertile(30, values), 2);
|
||||||
|
assert.equal(scoreTertile(50, values), 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('scoreTertile can score lower values higher for recency', () => {
|
||||||
|
const recencyDays = [2, 10, 20, 40, 80];
|
||||||
|
|
||||||
|
assert.equal(scoreTertile(2, recencyDays, false), 3);
|
||||||
|
assert.equal(scoreTertile(20, recencyDays, false), 2);
|
||||||
|
assert.equal(scoreTertile(80, recencyDays, false), 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getRfmSegment maps the 3x3 RFM matrix', () => {
|
||||||
|
assert.deepEqual(getRfmSegment(3, 3), { key: 'champions', label: 'Champions' });
|
||||||
|
assert.deepEqual(getRfmSegment(2, 2), { key: 'need_attention', label: 'Precisam de Atenção' });
|
||||||
|
assert.deepEqual(getRfmSegment(1, 1), { key: 'lost', label: 'Perdidos' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('buildRfmSegments summarizes segment count and revenue', () => {
|
||||||
|
const segments = buildRfmSegments([
|
||||||
|
{ segmentKey: 'champions', monetary: 100 },
|
||||||
|
{ segmentKey: 'champions', monetary: 50 },
|
||||||
|
{ segmentKey: 'lost', monetary: 25 }
|
||||||
|
]);
|
||||||
|
|
||||||
|
const champions = segments.find(segment => segment.key === 'champions');
|
||||||
|
const lost = segments.find(segment => segment.key === 'lost');
|
||||||
|
|
||||||
|
assert.equal(champions.count, 2);
|
||||||
|
assert.equal(champions.totalRevenue, 150);
|
||||||
|
assert.equal(champions.averageRevenue, 75);
|
||||||
|
assert.equal(lost.count, 1);
|
||||||
|
assert.equal(lost.totalRevenue, 25);
|
||||||
|
});
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ const ProductDetails = React.lazy(() => import('./pages/ProductDetails'));
|
|||||||
const Clients = React.lazy(() => import('./pages/Clients'));
|
const Clients = React.lazy(() => import('./pages/Clients'));
|
||||||
const ClientDetails = React.lazy(() => import('./pages/ClientDetails'));
|
const ClientDetails = React.lazy(() => import('./pages/ClientDetails'));
|
||||||
const Campaigns = React.lazy(() => import('./pages/Campaigns'));
|
const Campaigns = React.lazy(() => import('./pages/Campaigns'));
|
||||||
|
const Rfm = React.lazy(() => import('./pages/Rfm'));
|
||||||
const Login = React.lazy(() => import('./pages/Login'));
|
const Login = React.lazy(() => import('./pages/Login'));
|
||||||
|
|
||||||
function PrivateRoute({ children }: { children: React.ReactNode }) {
|
function PrivateRoute({ children }: { children: React.ReactNode }) {
|
||||||
@@ -38,6 +39,7 @@ function App() {
|
|||||||
<Route path="products/:id" element={<ProductDetails />} />
|
<Route path="products/:id" element={<ProductDetails />} />
|
||||||
<Route path="clients" element={<Clients />} />
|
<Route path="clients" element={<Clients />} />
|
||||||
<Route path="clients/:name" element={<ClientDetails />} />
|
<Route path="clients/:name" element={<ClientDetails />} />
|
||||||
|
<Route path="rfm" element={<Rfm />} />
|
||||||
<Route path="campaigns" element={<Campaigns />} />
|
<Route path="campaigns" element={<Campaigns />} />
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|||||||
@@ -2,15 +2,27 @@ import type { DateRange, OrderData } from '../types';
|
|||||||
import { parseOrderDate } from '../dataService';
|
import { parseOrderDate } from '../dataService';
|
||||||
import { filterOrdersByDateRange, getClientDisplayName, getOrderItemRevenue } from './orders';
|
import { filterOrdersByDateRange, getClientDisplayName, getOrderItemRevenue } from './orders';
|
||||||
|
|
||||||
export type ClientSortOption = 'recent' | 'spent_desc' | 'spent_asc' | 'items_desc' | 'items_asc';
|
export type ClientSortOption =
|
||||||
|
| 'recent'
|
||||||
|
| 'spent_desc'
|
||||||
|
| 'spent_asc'
|
||||||
|
| 'ticket_desc'
|
||||||
|
| 'ticket_asc'
|
||||||
|
| 'rfm_priority'
|
||||||
|
| 'items_desc'
|
||||||
|
| 'items_asc';
|
||||||
|
|
||||||
export interface ClientSummary {
|
export interface ClientSummary {
|
||||||
name: string;
|
name: string;
|
||||||
phone: string;
|
phone: string;
|
||||||
totalSpent: number;
|
totalSpent: number;
|
||||||
|
averageTicket: number;
|
||||||
totalItems: number;
|
totalItems: number;
|
||||||
orderCount: number;
|
orderCount: number;
|
||||||
lastPurchase: number;
|
lastPurchase: number;
|
||||||
|
clientType: string;
|
||||||
|
rfmScore: string;
|
||||||
|
rfmPriority: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GroupedClientOrder {
|
export interface GroupedClientOrder {
|
||||||
@@ -27,6 +39,64 @@ export interface ClientDetailsMetrics {
|
|||||||
clientPhone: string;
|
clientPhone: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const scoreTertile = (value: number, values: number[], higherIsBetter = true) => {
|
||||||
|
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 2;
|
||||||
|
|
||||||
|
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));
|
||||||
|
};
|
||||||
|
|
||||||
|
const getClientType = (recencyScore: number, valueScore: number) => {
|
||||||
|
const segmentMap: Record<string, string> = {
|
||||||
|
'3-3': 'Campeão',
|
||||||
|
'3-2': 'Potencial Leal',
|
||||||
|
'3-1': 'Novo Cliente',
|
||||||
|
'2-3': 'Cliente Leal',
|
||||||
|
'2-2': 'Precisa de Atenção',
|
||||||
|
'2-1': 'Quase Dormindo',
|
||||||
|
'1-3': 'Em Risco',
|
||||||
|
'1-2': 'Hibernando',
|
||||||
|
'1-1': 'Perdido'
|
||||||
|
};
|
||||||
|
|
||||||
|
return segmentMap[`${recencyScore}-${valueScore}`] || 'Perdido';
|
||||||
|
};
|
||||||
|
|
||||||
|
const enrichClientsWithRfmType = (
|
||||||
|
clients: Array<Omit<ClientSummary, 'averageTicket' | 'clientType' | 'rfmScore' | 'rfmPriority'>>,
|
||||||
|
dateRange: DateRange
|
||||||
|
): ClientSummary[] => {
|
||||||
|
const rangeEndTime = dateRange.end.getTime();
|
||||||
|
const recencyValues = clients.map(client => Math.max(0, Math.floor((rangeEndTime - client.lastPurchase) / 86400000)));
|
||||||
|
const frequencyValues = clients.map(client => client.orderCount);
|
||||||
|
const monetaryValues = clients.map(client => client.totalSpent);
|
||||||
|
|
||||||
|
return clients.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)));
|
||||||
|
const rfmPriority = (recencyScore * 100) + (valueScore * 10) + monetaryScore;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...client,
|
||||||
|
averageTicket: client.orderCount ? client.totalSpent / client.orderCount : 0,
|
||||||
|
clientType: getClientType(recencyScore, valueScore),
|
||||||
|
rfmScore: `${recencyScore}${frequencyScore}${monetaryScore}`,
|
||||||
|
rfmPriority
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
export const buildClientsSummary = (
|
export const buildClientsSummary = (
|
||||||
ordersData: OrderData[],
|
ordersData: OrderData[],
|
||||||
dateRange: DateRange,
|
dateRange: DateRange,
|
||||||
@@ -64,14 +134,14 @@ export const buildClientsSummary = (
|
|||||||
});
|
});
|
||||||
|
|
||||||
const normalizedSearch = searchTerm.trim().toLowerCase();
|
const normalizedSearch = searchTerm.trim().toLowerCase();
|
||||||
const clients = Object.keys(clientMap).map(name => ({
|
const clients = enrichClientsWithRfmType(Object.keys(clientMap).map(name => ({
|
||||||
name,
|
name,
|
||||||
phone: clientMap[name].phone,
|
phone: clientMap[name].phone,
|
||||||
totalSpent: clientMap[name].totalSpent,
|
totalSpent: clientMap[name].totalSpent,
|
||||||
totalItems: clientMap[name].totalItems,
|
totalItems: clientMap[name].totalItems,
|
||||||
orderCount: clientMap[name].uniqueOrders.size,
|
orderCount: clientMap[name].uniqueOrders.size,
|
||||||
lastPurchase: clientMap[name].lastPurchase
|
lastPurchase: clientMap[name].lastPurchase
|
||||||
}));
|
})), dateRange);
|
||||||
|
|
||||||
const filteredClients = normalizedSearch
|
const filteredClients = normalizedSearch
|
||||||
? clients.filter(client => client.name.toLowerCase().includes(normalizedSearch))
|
? clients.filter(client => client.name.toLowerCase().includes(normalizedSearch))
|
||||||
@@ -82,6 +152,9 @@ export const buildClientsSummary = (
|
|||||||
case 'recent': return b.lastPurchase - a.lastPurchase;
|
case 'recent': return b.lastPurchase - a.lastPurchase;
|
||||||
case 'spent_desc': return b.totalSpent - a.totalSpent;
|
case 'spent_desc': return b.totalSpent - a.totalSpent;
|
||||||
case 'spent_asc': return a.totalSpent - b.totalSpent;
|
case 'spent_asc': return a.totalSpent - b.totalSpent;
|
||||||
|
case 'ticket_desc': return b.averageTicket - a.averageTicket;
|
||||||
|
case 'ticket_asc': return a.averageTicket - b.averageTicket;
|
||||||
|
case 'rfm_priority': return b.rfmPriority - a.rfmPriority;
|
||||||
case 'items_desc': return b.totalItems - a.totalItems;
|
case 'items_desc': return b.totalItems - a.totalItems;
|
||||||
case 'items_asc': return a.totalItems - b.totalItems;
|
case 'items_asc': return a.totalItems - b.totalItems;
|
||||||
default: return 0;
|
default: return 0;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useCallback, useState, useEffect } from 'react';
|
import { useCallback, useState, useEffect } from 'react';
|
||||||
import { Outlet, Link, useLocation } from 'react-router-dom';
|
import { Outlet, Link, useLocation } from 'react-router-dom';
|
||||||
import { LayoutDashboard, Users, BarChart3, ChevronLeft, ChevronRight, Package, Loader2, LogOut, Megaphone } from 'lucide-react';
|
import { LayoutDashboard, Users, BarChart3, ChevronLeft, ChevronRight, Package, Loader2, LogOut, Megaphone, Grid3X3 } from 'lucide-react';
|
||||||
import type { DateRange, OrderData, StockData } from '../types';
|
import type { DateRange, OrderData, StockData } from '../types';
|
||||||
import { fetchData, fetchStock, logout } from '../dataService';
|
import { fetchData, fetchStock, logout } from '../dataService';
|
||||||
|
|
||||||
@@ -83,6 +83,7 @@ const Layout = () => {
|
|||||||
{ name: 'Dashboard', href: '/graph', icon: LayoutDashboard },
|
{ name: 'Dashboard', href: '/graph', icon: LayoutDashboard },
|
||||||
{ name: 'Produtos', href: '/products', icon: Package },
|
{ name: 'Produtos', href: '/products', icon: Package },
|
||||||
{ name: 'Clientes', href: '/clients', icon: Users },
|
{ name: 'Clientes', href: '/clients', icon: Users },
|
||||||
|
{ name: 'RFM', href: '/rfm', icon: Grid3X3 },
|
||||||
{ name: 'Campanhas', href: '/campaigns', icon: Megaphone },
|
{ name: 'Campanhas', href: '/campaigns', icon: Megaphone },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { CampaignPreview, CampaignProcessSummary, CampaignQueueSummary, DashboardAnalytics, DateRange, OrderData, StockData } from './types';
|
import type { CampaignPreview, CampaignProcessSummary, CampaignQueueSummary, DashboardAnalytics, DateRange, OrderData, RfmAnalytics, StockData } from './types';
|
||||||
|
|
||||||
const API_URL = import.meta.env.VITE_API_URL || '/api';
|
const API_URL = import.meta.env.VITE_API_URL || '/api';
|
||||||
|
|
||||||
@@ -111,6 +111,21 @@ export const fetchDashboardAnalytics = async (dateRange: DateRange): Promise<Das
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const fetchRfmAnalytics = async (dateRange: DateRange): Promise<RfmAnalytics | null> => {
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
start: formatDateParam(dateRange.start),
|
||||||
|
end: formatDateParam(dateRange.end)
|
||||||
|
});
|
||||||
|
const response = await authFetch(`/analytics/rfm?${params.toString()}`);
|
||||||
|
if (!response.ok) return null;
|
||||||
|
return await response.json();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Fetch RFM analytics failed', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export const fetchCampaigns = async (): Promise<CampaignQueueSummary | null> => {
|
export const fetchCampaigns = async (): Promise<CampaignQueueSummary | null> => {
|
||||||
try {
|
try {
|
||||||
const response = await authFetch('/campaigns');
|
const response = await authFetch('/campaigns');
|
||||||
|
|||||||
@@ -1,10 +1,66 @@
|
|||||||
import { useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { Link, useOutletContext } from 'react-router-dom';
|
import { Link, useOutletContext } from 'react-router-dom';
|
||||||
import { Search, ChevronRight, Filter, ChevronLeft, Download } from 'lucide-react';
|
import { Search, ChevronRight, Filter, ChevronLeft, Download } from 'lucide-react';
|
||||||
import type { OrderData, DateRange } from '../types';
|
import type { OrderData, DateRange, RfmAnalytics, RfmClient } from '../types';
|
||||||
import { exportToCSV } from '../dataService';
|
import { exportToCSV, fetchRfmAnalytics } from '../dataService';
|
||||||
import DateRangePicker from '../components/DateRangePicker';
|
import DateRangePicker from '../components/DateRangePicker';
|
||||||
import { buildClientsSummary, type ClientSortOption } from '../analytics/clients';
|
import { buildClientsSummary, type ClientSortOption, type ClientSummary } from '../analytics/clients';
|
||||||
|
|
||||||
|
const clientTypeStyles: Record<string, string> = {
|
||||||
|
'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',
|
||||||
|
'Novo Cliente': 'border-cyan-500/30 bg-cyan-500/15 text-cyan-300',
|
||||||
|
'Cliente Leal': 'border-blue-500/30 bg-blue-500/15 text-blue-300',
|
||||||
|
'Precisa de Atenção': 'border-amber-500/30 bg-amber-500/15 text-amber-300',
|
||||||
|
'Quase Dormindo': 'border-orange-500/30 bg-orange-500/15 text-orange-300',
|
||||||
|
'Em Risco': 'border-rose-500/30 bg-rose-500/15 text-rose-300',
|
||||||
|
'Hibernando': 'border-fuchsia-500/30 bg-fuchsia-500/15 text-fuchsia-300',
|
||||||
|
'Perdido': 'border-zinc-500/30 bg-zinc-500/15 text-zinc-300'
|
||||||
|
};
|
||||||
|
|
||||||
|
const clientTypes = [
|
||||||
|
'Campeão',
|
||||||
|
'Potencial Leal',
|
||||||
|
'Novo Cliente',
|
||||||
|
'Cliente Leal',
|
||||||
|
'Precisa de Atenção',
|
||||||
|
'Quase Dormindo',
|
||||||
|
'Em Risco',
|
||||||
|
'Hibernando',
|
||||||
|
'Perdido'
|
||||||
|
];
|
||||||
|
|
||||||
|
const backendSegmentToClientType: Record<string, string> = {
|
||||||
|
champions: 'Campeão',
|
||||||
|
potential_loyalists: 'Potencial Leal',
|
||||||
|
new_customers: 'Novo Cliente',
|
||||||
|
loyal_customers: 'Cliente Leal',
|
||||||
|
need_attention: 'Precisa de Atenção',
|
||||||
|
about_to_sleep: 'Quase Dormindo',
|
||||||
|
at_risk: 'Em Risco',
|
||||||
|
hibernating: 'Hibernando',
|
||||||
|
lost: 'Perdido'
|
||||||
|
};
|
||||||
|
|
||||||
|
const getRfmPriority = (client: Pick<RfmClient, 'recencyScore' | 'valueScore' | 'monetaryScore'>) => {
|
||||||
|
return (client.recencyScore * 100) + (client.valueScore * 10) + client.monetaryScore;
|
||||||
|
};
|
||||||
|
|
||||||
|
const sortClients = (clients: ClientSummary[], sortBy: ClientSortOption) => {
|
||||||
|
return [...clients].sort((a, b) => {
|
||||||
|
switch (sortBy) {
|
||||||
|
case 'recent': return b.lastPurchase - a.lastPurchase;
|
||||||
|
case 'spent_desc': return b.totalSpent - a.totalSpent;
|
||||||
|
case 'spent_asc': return a.totalSpent - b.totalSpent;
|
||||||
|
case 'ticket_desc': return b.averageTicket - a.averageTicket;
|
||||||
|
case 'ticket_asc': return a.averageTicket - b.averageTicket;
|
||||||
|
case 'rfm_priority': return b.rfmPriority - a.rfmPriority;
|
||||||
|
case 'items_desc': return b.totalItems - a.totalItems;
|
||||||
|
case 'items_asc': return a.totalItems - b.totalItems;
|
||||||
|
default: return 0;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const Clients = () => {
|
const Clients = () => {
|
||||||
const { dateRange, setDateRange, ordersData } = useOutletContext<{
|
const { dateRange, setDateRange, ordersData } = useOutletContext<{
|
||||||
@@ -14,14 +70,69 @@ const Clients = () => {
|
|||||||
}>();
|
}>();
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
const [sortBy, setSortBy] = useState<ClientSortOption>('recent');
|
const [sortBy, setSortBy] = useState<ClientSortOption>('recent');
|
||||||
|
const [clientTypeFilter, setClientTypeFilter] = useState('all');
|
||||||
|
const [rfmAnalytics, setRfmAnalytics] = useState<RfmAnalytics | null>(null);
|
||||||
|
|
||||||
// Pagination state
|
// Pagination state
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
const [itemsPerPage, setItemsPerPage] = useState(10);
|
const [itemsPerPage, setItemsPerPage] = useState(10);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let isMounted = true;
|
||||||
|
|
||||||
|
const loadRfm = async () => {
|
||||||
|
const data = await fetchRfmAnalytics(dateRange);
|
||||||
|
if (isMounted) setRfmAnalytics(data);
|
||||||
|
};
|
||||||
|
|
||||||
|
void loadRfm();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
isMounted = false;
|
||||||
|
};
|
||||||
|
}, [dateRange]);
|
||||||
|
|
||||||
|
const rfmClientsByIdentity = useMemo(() => {
|
||||||
|
const map = new Map<string, RfmClient>();
|
||||||
|
|
||||||
|
(rfmAnalytics?.clients || []).forEach(client => {
|
||||||
|
if (client.phone) map.set(`phone:${client.phone}`, client);
|
||||||
|
map.set(`name:${client.name.toLowerCase()}`, client);
|
||||||
|
});
|
||||||
|
|
||||||
|
return map;
|
||||||
|
}, [rfmAnalytics]);
|
||||||
|
|
||||||
|
const allClientsData = useMemo(() => {
|
||||||
|
const localClients = buildClientsSummary(ordersData, dateRange, searchTerm, sortBy);
|
||||||
|
const enrichedClients = localClients.map(client => {
|
||||||
|
const rfmClient = (client.phone && rfmClientsByIdentity.get(`phone:${client.phone}`)) ||
|
||||||
|
rfmClientsByIdentity.get(`name:${client.name.toLowerCase()}`);
|
||||||
|
|
||||||
|
if (!rfmClient) return client;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...client,
|
||||||
|
clientType: backendSegmentToClientType[rfmClient.segmentKey] || client.clientType,
|
||||||
|
rfmScore: rfmClient.rfmScore,
|
||||||
|
rfmPriority: getRfmPriority(rfmClient)
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return sortClients(enrichedClients, sortBy);
|
||||||
|
}, [searchTerm, sortBy, ordersData, dateRange, rfmClientsByIdentity]);
|
||||||
|
|
||||||
const clientsData = useMemo(() => {
|
const clientsData = useMemo(() => {
|
||||||
return buildClientsSummary(ordersData, dateRange, searchTerm, sortBy);
|
if (clientTypeFilter === 'all') return allClientsData;
|
||||||
}, [searchTerm, sortBy, ordersData, dateRange]);
|
return allClientsData.filter(client => client.clientType === clientTypeFilter);
|
||||||
|
}, [allClientsData, clientTypeFilter]);
|
||||||
|
|
||||||
|
const clientTypeCounts = useMemo(() => {
|
||||||
|
return clientTypes.reduce<Record<string, number>>((counts, type) => {
|
||||||
|
counts[type] = allClientsData.filter(client => client.clientType === type).length;
|
||||||
|
return counts;
|
||||||
|
}, {});
|
||||||
|
}, [allClientsData]);
|
||||||
|
|
||||||
// Pagination logic
|
// Pagination logic
|
||||||
const totalPages = Math.ceil(clientsData.length / itemsPerPage);
|
const totalPages = Math.ceil(clientsData.length / itemsPerPage);
|
||||||
@@ -32,6 +143,11 @@ const Clients = () => {
|
|||||||
return new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(value);
|
return new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(value);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const selectClientType = (type: string) => {
|
||||||
|
setClientTypeFilter(current => current === type ? 'all' : type);
|
||||||
|
setCurrentPage(1);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex flex-col xl:flex-row xl:items-center justify-between gap-4">
|
<div className="flex flex-col xl:flex-row xl:items-center justify-between gap-4">
|
||||||
@@ -62,6 +178,9 @@ const Clients = () => {
|
|||||||
<option value="recent">Mais Recentes</option>
|
<option value="recent">Mais Recentes</option>
|
||||||
<option value="spent_desc">Maior Gasto</option>
|
<option value="spent_desc">Maior Gasto</option>
|
||||||
<option value="spent_asc">Menor Gasto</option>
|
<option value="spent_asc">Menor Gasto</option>
|
||||||
|
<option value="ticket_desc">Maior Ticket Médio</option>
|
||||||
|
<option value="ticket_asc">Menor Ticket Médio</option>
|
||||||
|
<option value="rfm_priority">Prioridade RFM</option>
|
||||||
<option value="items_desc">Mais Produtos</option>
|
<option value="items_desc">Mais Produtos</option>
|
||||||
<option value="items_asc">Menos Produtos</option>
|
<option value="items_asc">Menos Produtos</option>
|
||||||
</select>
|
</select>
|
||||||
@@ -86,7 +205,10 @@ const Clients = () => {
|
|||||||
const exportData = clientsData.map(client => ({
|
const exportData = clientsData.map(client => ({
|
||||||
'Nome do Cliente': client.name,
|
'Nome do Cliente': client.name,
|
||||||
'Telefone/WhatsApp': client.phone || 'N/A',
|
'Telefone/WhatsApp': client.phone || 'N/A',
|
||||||
|
'Tipo de Cliente': client.clientType,
|
||||||
|
'RFM': client.rfmScore,
|
||||||
'Total Gasto (R$)': client.totalSpent.toFixed(2).replace('.', ','),
|
'Total Gasto (R$)': client.totalSpent.toFixed(2).replace('.', ','),
|
||||||
|
'Ticket Médio (R$)': client.averageTicket.toFixed(2).replace('.', ','),
|
||||||
'Produtos Comprados': client.totalItems,
|
'Produtos Comprados': client.totalItems,
|
||||||
'Total de Pedidos': client.orderCount,
|
'Total de Pedidos': client.orderCount,
|
||||||
'Última Compra': new Date(client.lastPurchase).toLocaleDateString('pt-BR')
|
'Última Compra': new Date(client.lastPurchase).toLocaleDateString('pt-BR')
|
||||||
@@ -102,6 +224,35 @@ const Clients = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white dark:bg-dark-card border border-zinc-200 dark:border-dark-border rounded-2xl p-4 shadow-sm">
|
||||||
|
<div className="mb-4">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-sm font-bold text-zinc-900 dark:text-dark-text">Tipos de Clientes</h2>
|
||||||
|
<p className="text-xs font-semibold text-zinc-500 dark:text-dark-muted">
|
||||||
|
Classificação RFM sincronizada com a página RFM quando disponível.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{clientTypes.map(type => (
|
||||||
|
<button
|
||||||
|
key={type}
|
||||||
|
onClick={() => selectClientType(type)}
|
||||||
|
title={clientTypeFilter === type ? 'Clique para limpar o filtro' : `Filtrar por ${type}`}
|
||||||
|
className={`inline-flex items-center gap-2 rounded-full border px-3 py-1.5 text-xs font-bold transition-all cursor-pointer hover:-translate-y-0.5 ${
|
||||||
|
clientTypeFilter === type
|
||||||
|
? `${clientTypeStyles[type]} ring-1 ring-current shadow-sm`
|
||||||
|
: clientTypeStyles[type]
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span>{type}</span>
|
||||||
|
<span className="rounded-full bg-black/20 px-2 py-0.5 text-[10px]">{clientTypeCounts[type] || 0}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="bg-white dark:bg-dark-card border border-zinc-200 dark:border-dark-border rounded-2xl overflow-hidden shadow-sm">
|
<div className="bg-white dark:bg-dark-card border border-zinc-200 dark:border-dark-border rounded-2xl overflow-hidden shadow-sm">
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full text-left text-sm">
|
<table className="w-full text-left text-sm">
|
||||||
@@ -109,7 +260,10 @@ const Clients = () => {
|
|||||||
<tr>
|
<tr>
|
||||||
<th className="px-6 py-4 font-bold uppercase tracking-wider text-[10px]">Posição</th>
|
<th className="px-6 py-4 font-bold uppercase tracking-wider text-[10px]">Posição</th>
|
||||||
<th className="px-6 py-4 font-bold uppercase tracking-wider text-[10px]">Nome do Cliente</th>
|
<th className="px-6 py-4 font-bold uppercase tracking-wider text-[10px]">Nome do Cliente</th>
|
||||||
|
<th className="px-6 py-4 font-bold uppercase tracking-wider text-[10px]">Tipo</th>
|
||||||
|
<th className="px-6 py-4 font-bold uppercase tracking-wider text-[10px]">RFM</th>
|
||||||
<th className="px-6 py-4 font-bold uppercase tracking-wider text-[10px]">Total Gasto</th>
|
<th className="px-6 py-4 font-bold uppercase tracking-wider text-[10px]">Total Gasto</th>
|
||||||
|
<th className="px-6 py-4 font-bold uppercase tracking-wider text-[10px]">Ticket Médio</th>
|
||||||
<th className="px-6 py-4 font-bold uppercase tracking-wider text-[10px]">Produtos Comprados</th>
|
<th className="px-6 py-4 font-bold uppercase tracking-wider text-[10px]">Produtos Comprados</th>
|
||||||
<th className="px-6 py-4 font-bold uppercase tracking-wider text-[10px] text-right">Ações</th>
|
<th className="px-6 py-4 font-bold uppercase tracking-wider text-[10px] text-right">Ações</th>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -123,7 +277,25 @@ const Clients = () => {
|
|||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-2.5 font-semibold text-zinc-900 dark:text-dark-text">{client.name}</td>
|
<td className="px-6 py-2.5 font-semibold text-zinc-900 dark:text-dark-text">{client.name}</td>
|
||||||
|
<td className="px-6 py-2.5">
|
||||||
|
<span className={`inline-flex rounded-full border px-3 py-1 text-xs font-bold ${clientTypeStyles[client.clientType] || clientTypeStyles.Perdido}`}>
|
||||||
|
{client.clientType}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-2.5">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{client.rfmScore.split('').map((score, scoreIndex) => (
|
||||||
|
<span
|
||||||
|
key={`${client.name}-${scoreIndex}`}
|
||||||
|
className="inline-flex h-6 w-6 items-center justify-center rounded-md border border-dark-border bg-dark-input text-[11px] font-bold text-dark-text"
|
||||||
|
>
|
||||||
|
{score}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
<td className="px-6 py-2.5 text-brand-primary font-bold">{formatCurrency(client.totalSpent)}</td>
|
<td className="px-6 py-2.5 text-brand-primary font-bold">{formatCurrency(client.totalSpent)}</td>
|
||||||
|
<td className="px-6 py-2.5 text-zinc-700 dark:text-dark-text font-semibold">{formatCurrency(client.averageTicket)}</td>
|
||||||
<td className="px-6 py-2.5 text-zinc-500 dark:text-dark-muted text-xs font-medium">
|
<td className="px-6 py-2.5 text-zinc-500 dark:text-dark-muted text-xs font-medium">
|
||||||
{client.totalItems} un. ({client.orderCount} {client.orderCount === 1 ? 'pedido' : 'pedidos'})
|
{client.totalItems} un. ({client.orderCount} {client.orderCount === 1 ? 'pedido' : 'pedidos'})
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
740
src/pages/Rfm.tsx
Normal file
740
src/pages/Rfm.tsx
Normal file
@@ -0,0 +1,740 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import { Link, useOutletContext } from 'react-router-dom';
|
||||||
|
import { Database, Download, Filter, Loader2, Search, Sparkles, Users } from 'lucide-react';
|
||||||
|
import DateRangePicker from '../components/DateRangePicker';
|
||||||
|
import { exportToCSV, fetchRfmAnalytics } from '../dataService';
|
||||||
|
import type { DateRange, RfmAnalytics, RfmClient, RfmSegment } from '../types';
|
||||||
|
|
||||||
|
const emptyClients: RfmClient[] = [];
|
||||||
|
const emptySegments: RfmSegment[] = [];
|
||||||
|
|
||||||
|
const segmentStyles: Record<string, { accent: string; bg: string; text: string }> = {
|
||||||
|
champions: { accent: 'bg-emerald-500', bg: 'bg-emerald-500/10', text: 'text-emerald-400' },
|
||||||
|
potential_loyalists: { accent: 'bg-sky-500', bg: 'bg-sky-500/10', text: 'text-sky-400' },
|
||||||
|
new_customers: { accent: 'bg-cyan-500', bg: 'bg-cyan-500/10', text: 'text-cyan-400' },
|
||||||
|
loyal_customers: { accent: 'bg-blue-500', bg: 'bg-blue-500/10', text: 'text-blue-400' },
|
||||||
|
need_attention: { accent: 'bg-amber-500', bg: 'bg-amber-500/10', text: 'text-amber-400' },
|
||||||
|
about_to_sleep: { accent: 'bg-orange-500', bg: 'bg-orange-500/10', text: 'text-orange-400' },
|
||||||
|
at_risk: { accent: 'bg-rose-500', bg: 'bg-rose-500/10', text: 'text-rose-400' },
|
||||||
|
hibernating: { accent: 'bg-fuchsia-500', bg: 'bg-fuchsia-500/10', text: 'text-fuchsia-400' },
|
||||||
|
lost: { accent: 'bg-zinc-500', bg: 'bg-zinc-500/10', text: 'text-zinc-400' }
|
||||||
|
};
|
||||||
|
|
||||||
|
const rfmSegmentDefinitions: Array<Pick<RfmSegment, 'key' | 'label'>> = [
|
||||||
|
{ key: 'champions', label: 'Champions' },
|
||||||
|
{ key: 'potential_loyalists', label: 'Potenciais Leais' },
|
||||||
|
{ key: 'new_customers', label: 'Novos Clientes' },
|
||||||
|
{ key: 'loyal_customers', label: 'Clientes Leais' },
|
||||||
|
{ key: 'need_attention', label: 'Precisam de Atenção' },
|
||||||
|
{ key: 'about_to_sleep', label: 'Quase Dormindo' },
|
||||||
|
{ key: 'at_risk', label: 'Em Risco' },
|
||||||
|
{ key: 'hibernating', label: 'Hibernando' },
|
||||||
|
{ key: 'lost', label: 'Perdidos' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const segmentDescriptions: Record<string, string> = {
|
||||||
|
champions: 'Recentes, frequentes e valiosos',
|
||||||
|
potential_loyalists: 'Recentes com bom potencial',
|
||||||
|
new_customers: 'Primeira compra recente',
|
||||||
|
loyal_customers: 'Valiosos, mas menos recentes',
|
||||||
|
need_attention: 'Base intermediária para nutrir',
|
||||||
|
about_to_sleep: 'Baixo valor começando a esfriar',
|
||||||
|
at_risk: 'Valiosos sem compra recente',
|
||||||
|
hibernating: 'Sem compra recente e valor médio',
|
||||||
|
lost: 'Baixa atividade e distante'
|
||||||
|
};
|
||||||
|
|
||||||
|
const segmentActions: Record<string, string> = {
|
||||||
|
champions: 'Oferecer acesso antecipado, benefícios VIP e lançamentos.',
|
||||||
|
potential_loyalists: 'Estimular a próxima compra com recomendações personalizadas.',
|
||||||
|
new_customers: 'Enviar boas-vindas e incentivo para a segunda compra.',
|
||||||
|
loyal_customers: 'Manter relacionamento com ofertas relevantes e recorrentes.',
|
||||||
|
need_attention: 'Reativar interesse com campanha leve e produtos recentes.',
|
||||||
|
about_to_sleep: 'Enviar lembrete antes que o cliente esfrie completamente.',
|
||||||
|
at_risk: 'Priorizar recuperação com oferta forte ou contato direto.',
|
||||||
|
hibernating: 'Testar reativação de baixo custo com mensagem objetiva.',
|
||||||
|
lost: 'Evitar alto investimento; usar apenas campanhas ocasionais.'
|
||||||
|
};
|
||||||
|
|
||||||
|
const segmentByScore: 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 demoClients: RfmClient[] = [
|
||||||
|
{
|
||||||
|
name: 'Ana Paula Ribeiro',
|
||||||
|
phone: '+55 11 90000-0001',
|
||||||
|
monetary: 4820,
|
||||||
|
frequency: 12,
|
||||||
|
quantityPurchased: 38,
|
||||||
|
lastPurchaseDate: '2026-06-10',
|
||||||
|
recencyDays: 1,
|
||||||
|
recencyScore: 3,
|
||||||
|
frequencyScore: 3,
|
||||||
|
monetaryScore: 3,
|
||||||
|
valueScore: 3,
|
||||||
|
rfmScore: '333',
|
||||||
|
segmentKey: 'champions',
|
||||||
|
segmentLabel: 'Champions'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Bruno Ferreira',
|
||||||
|
phone: '+55 11 90000-0002',
|
||||||
|
monetary: 1680,
|
||||||
|
frequency: 5,
|
||||||
|
quantityPurchased: 14,
|
||||||
|
lastPurchaseDate: '2026-06-09',
|
||||||
|
recencyDays: 2,
|
||||||
|
recencyScore: 3,
|
||||||
|
frequencyScore: 2,
|
||||||
|
monetaryScore: 2,
|
||||||
|
valueScore: 2,
|
||||||
|
rfmScore: '322',
|
||||||
|
segmentKey: 'potential_loyalists',
|
||||||
|
segmentLabel: 'Potenciais Leais'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Carla Mendes',
|
||||||
|
phone: '+55 11 90000-0003',
|
||||||
|
monetary: 249,
|
||||||
|
frequency: 1,
|
||||||
|
quantityPurchased: 2,
|
||||||
|
lastPurchaseDate: '2026-06-08',
|
||||||
|
recencyDays: 3,
|
||||||
|
recencyScore: 3,
|
||||||
|
frequencyScore: 1,
|
||||||
|
monetaryScore: 1,
|
||||||
|
valueScore: 1,
|
||||||
|
rfmScore: '311',
|
||||||
|
segmentKey: 'new_customers',
|
||||||
|
segmentLabel: 'Novos Clientes'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Diego Nascimento',
|
||||||
|
phone: '+55 11 90000-0004',
|
||||||
|
monetary: 3920,
|
||||||
|
frequency: 10,
|
||||||
|
quantityPurchased: 31,
|
||||||
|
lastPurchaseDate: '2026-05-24',
|
||||||
|
recencyDays: 18,
|
||||||
|
recencyScore: 2,
|
||||||
|
frequencyScore: 3,
|
||||||
|
monetaryScore: 3,
|
||||||
|
valueScore: 3,
|
||||||
|
rfmScore: '233',
|
||||||
|
segmentKey: 'loyal_customers',
|
||||||
|
segmentLabel: 'Clientes Leais'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Elisa Costa',
|
||||||
|
phone: '+55 11 90000-0005',
|
||||||
|
monetary: 1210,
|
||||||
|
frequency: 4,
|
||||||
|
quantityPurchased: 9,
|
||||||
|
lastPurchaseDate: '2026-05-18',
|
||||||
|
recencyDays: 24,
|
||||||
|
recencyScore: 2,
|
||||||
|
frequencyScore: 2,
|
||||||
|
monetaryScore: 2,
|
||||||
|
valueScore: 2,
|
||||||
|
rfmScore: '222',
|
||||||
|
segmentKey: 'need_attention',
|
||||||
|
segmentLabel: 'Precisam de Atenção'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Felipe Azevedo',
|
||||||
|
phone: '+55 11 90000-0006',
|
||||||
|
monetary: 390,
|
||||||
|
frequency: 2,
|
||||||
|
quantityPurchased: 3,
|
||||||
|
lastPurchaseDate: '2026-05-12',
|
||||||
|
recencyDays: 30,
|
||||||
|
recencyScore: 2,
|
||||||
|
frequencyScore: 1,
|
||||||
|
monetaryScore: 1,
|
||||||
|
valueScore: 1,
|
||||||
|
rfmScore: '211',
|
||||||
|
segmentKey: 'about_to_sleep',
|
||||||
|
segmentLabel: 'Quase Dormindo'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Gabriela Martins',
|
||||||
|
phone: '+55 11 90000-0007',
|
||||||
|
monetary: 5350,
|
||||||
|
frequency: 14,
|
||||||
|
quantityPurchased: 44,
|
||||||
|
lastPurchaseDate: '2026-03-28',
|
||||||
|
recencyDays: 75,
|
||||||
|
recencyScore: 1,
|
||||||
|
frequencyScore: 3,
|
||||||
|
monetaryScore: 3,
|
||||||
|
valueScore: 3,
|
||||||
|
rfmScore: '133',
|
||||||
|
segmentKey: 'at_risk',
|
||||||
|
segmentLabel: 'Em Risco'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Henrique Lima',
|
||||||
|
phone: '+55 11 90000-0008',
|
||||||
|
monetary: 980,
|
||||||
|
frequency: 4,
|
||||||
|
quantityPurchased: 8,
|
||||||
|
lastPurchaseDate: '2026-03-06',
|
||||||
|
recencyDays: 97,
|
||||||
|
recencyScore: 1,
|
||||||
|
frequencyScore: 2,
|
||||||
|
monetaryScore: 2,
|
||||||
|
valueScore: 2,
|
||||||
|
rfmScore: '122',
|
||||||
|
segmentKey: 'hibernating',
|
||||||
|
segmentLabel: 'Hibernando'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Isabela Rocha',
|
||||||
|
phone: '+55 11 90000-0009',
|
||||||
|
monetary: 120,
|
||||||
|
frequency: 1,
|
||||||
|
quantityPurchased: 1,
|
||||||
|
lastPurchaseDate: '2026-01-29',
|
||||||
|
recencyDays: 133,
|
||||||
|
recencyScore: 1,
|
||||||
|
frequencyScore: 1,
|
||||||
|
monetaryScore: 1,
|
||||||
|
valueScore: 1,
|
||||||
|
rfmScore: '111',
|
||||||
|
segmentKey: 'lost',
|
||||||
|
segmentLabel: 'Perdidos'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const demoSegments: RfmSegment[] = rfmSegmentDefinitions.map(segment => {
|
||||||
|
const segmentClients = demoClients.filter(client => client.segmentKey === segment.key);
|
||||||
|
const totalRevenue = segmentClients.reduce((sum, client) => sum + client.monetary, 0);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...segment,
|
||||||
|
count: segmentClients.length,
|
||||||
|
totalRevenue,
|
||||||
|
averageRevenue: segmentClients.length ? totalRevenue / segmentClients.length : 0
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const demoAnalytics: RfmAnalytics = {
|
||||||
|
range: {
|
||||||
|
start: '2026-01-01',
|
||||||
|
end: '2026-06-11'
|
||||||
|
},
|
||||||
|
clients: demoClients,
|
||||||
|
segments: demoSegments,
|
||||||
|
matrix: {
|
||||||
|
recencyScores: [3, 2, 1],
|
||||||
|
valueScores: [1, 2, 3]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatCurrency = (value: number) => {
|
||||||
|
return new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (value: string) => {
|
||||||
|
if (!value) return 'N/A';
|
||||||
|
return new Date(value).toLocaleDateString('pt-BR');
|
||||||
|
};
|
||||||
|
|
||||||
|
const scoreLabel = (score: number) => {
|
||||||
|
if (score === 3) return 'Alto';
|
||||||
|
if (score === 2) return 'Médio';
|
||||||
|
return 'Baixo';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getSegment = (segments: RfmSegment[], key: string) => {
|
||||||
|
return segments.find(segment => segment.key === key);
|
||||||
|
};
|
||||||
|
|
||||||
|
const segmentForCell = (segments: RfmSegment[], recencyScore: number, valueScore: number) => {
|
||||||
|
const matrixKey = `${recencyScore}-${valueScore}`;
|
||||||
|
return getSegment(segments, segmentByScore[matrixKey]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const ScoreBadge = ({ value }: { value: number }) => (
|
||||||
|
<span className="inline-flex h-7 w-7 items-center justify-center rounded-lg border border-dark-border bg-dark-input text-xs font-bold text-dark-text">
|
||||||
|
{value}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
|
||||||
|
const Rfm = () => {
|
||||||
|
const { dateRange, setDateRange, refreshInterval, setRefreshInterval } = useOutletContext<{
|
||||||
|
dateRange: DateRange;
|
||||||
|
setDateRange: (range: DateRange) => void;
|
||||||
|
refreshInterval: number;
|
||||||
|
setRefreshInterval: (interval: number) => void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const [analytics, setAnalytics] = useState<RfmAnalytics | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
const [segmentFilter, setSegmentFilter] = useState('all');
|
||||||
|
const [selectedSegmentKey, setSelectedSegmentKey] = useState('');
|
||||||
|
const [isDemoMode, setIsDemoMode] = useState(false);
|
||||||
|
|
||||||
|
const loadRfm = useCallback(async (range: DateRange) => {
|
||||||
|
setIsLoading(true);
|
||||||
|
const data = await fetchRfmAnalytics(range);
|
||||||
|
setAnalytics(data);
|
||||||
|
setIsLoading(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// RFM is calculated server-side from the selected date range.
|
||||||
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||||
|
void loadRfm(dateRange);
|
||||||
|
}, [dateRange, loadRfm]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (refreshInterval === 0) return;
|
||||||
|
|
||||||
|
const intervalId = setInterval(() => {
|
||||||
|
void loadRfm(dateRange);
|
||||||
|
}, refreshInterval);
|
||||||
|
|
||||||
|
return () => clearInterval(intervalId);
|
||||||
|
}, [dateRange, loadRfm, refreshInterval]);
|
||||||
|
|
||||||
|
const visibleAnalytics = isDemoMode ? demoAnalytics : analytics;
|
||||||
|
const clients = visibleAnalytics?.clients || emptyClients;
|
||||||
|
const segments = useMemo(() => {
|
||||||
|
const sourceSegments = visibleAnalytics?.segments || emptySegments;
|
||||||
|
return rfmSegmentDefinitions.map(definition => {
|
||||||
|
const segment = sourceSegments.find(item => item.key === definition.key);
|
||||||
|
return segment || {
|
||||||
|
...definition,
|
||||||
|
count: 0,
|
||||||
|
totalRevenue: 0,
|
||||||
|
averageRevenue: 0
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, [visibleAnalytics]);
|
||||||
|
|
||||||
|
const toggleDemoMode = () => {
|
||||||
|
setIsDemoMode(current => !current);
|
||||||
|
setSegmentFilter('all');
|
||||||
|
setSearchTerm('');
|
||||||
|
setSelectedSegmentKey('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const filteredClients = useMemo(() => {
|
||||||
|
const normalizedSearch = searchTerm.trim().toLowerCase();
|
||||||
|
|
||||||
|
return clients.filter(client => {
|
||||||
|
const matchesSegment = segmentFilter === 'all' || client.segmentKey === segmentFilter;
|
||||||
|
const matchesSearch = !normalizedSearch ||
|
||||||
|
client.name.toLowerCase().includes(normalizedSearch) ||
|
||||||
|
client.phone.toLowerCase().includes(normalizedSearch);
|
||||||
|
|
||||||
|
return matchesSegment && matchesSearch;
|
||||||
|
});
|
||||||
|
}, [clients, searchTerm, segmentFilter]);
|
||||||
|
|
||||||
|
const totals = useMemo(() => {
|
||||||
|
const totalRevenue = clients.reduce((sum, client) => sum + client.monetary, 0);
|
||||||
|
const activeSegments = segments.filter(segment => segment.count > 0).length;
|
||||||
|
const topSegment = [...segments].sort((a, b) => b.totalRevenue - a.totalRevenue)[0];
|
||||||
|
|
||||||
|
return { totalRevenue, activeSegments, topSegment };
|
||||||
|
}, [clients, segments]);
|
||||||
|
|
||||||
|
const segmentInsights = useMemo(() => {
|
||||||
|
return segments.reduce<Record<string, {
|
||||||
|
averageRecencyDays: number;
|
||||||
|
averageTicket: number;
|
||||||
|
customerPercent: number;
|
||||||
|
revenuePercent: number;
|
||||||
|
topClients: RfmClient[];
|
||||||
|
}>>((insights, segment) => {
|
||||||
|
const segmentClients = clients.filter(client => client.segmentKey === segment.key);
|
||||||
|
const totalOrders = segmentClients.reduce((sum, client) => sum + client.frequency, 0);
|
||||||
|
const totalRecency = segmentClients.reduce((sum, client) => sum + client.recencyDays, 0);
|
||||||
|
|
||||||
|
insights[segment.key] = {
|
||||||
|
averageRecencyDays: segmentClients.length ? totalRecency / segmentClients.length : 0,
|
||||||
|
averageTicket: totalOrders ? segment.totalRevenue / totalOrders : 0,
|
||||||
|
customerPercent: clients.length ? (segment.count / clients.length) * 100 : 0,
|
||||||
|
revenuePercent: totals.totalRevenue ? (segment.totalRevenue / totals.totalRevenue) * 100 : 0,
|
||||||
|
topClients: [...segmentClients].sort((a, b) => b.monetary - a.monetary).slice(0, 3)
|
||||||
|
};
|
||||||
|
|
||||||
|
return insights;
|
||||||
|
}, {});
|
||||||
|
}, [clients, segments, totals.totalRevenue]);
|
||||||
|
|
||||||
|
const selectedSegment = selectedSegmentKey ? getSegment(segments, selectedSegmentKey) : undefined;
|
||||||
|
const selectedSegmentStyle = segmentStyles[selectedSegment?.key || 'lost'] || segmentStyles.lost;
|
||||||
|
const selectedSegmentInsights = selectedSegment ? segmentInsights[selectedSegment.key] : null;
|
||||||
|
|
||||||
|
const handleExport = () => {
|
||||||
|
const exportData = filteredClients.map(client => ({
|
||||||
|
Cliente: client.name,
|
||||||
|
Telefone: client.phone,
|
||||||
|
Segmento: client.segmentLabel,
|
||||||
|
RFM: client.rfmScore,
|
||||||
|
Recencia: client.recencyScore,
|
||||||
|
Frequencia: client.frequencyScore,
|
||||||
|
Monetario: client.monetaryScore,
|
||||||
|
'Dias desde ultima compra': client.recencyDays,
|
||||||
|
Pedidos: client.frequency,
|
||||||
|
'Ticket medio (R$)': (client.frequency ? client.monetary / client.frequency : 0).toFixed(2).replace('.', ','),
|
||||||
|
'Total gasto (R$)': client.monetary.toFixed(2).replace('.', ','),
|
||||||
|
'Ultima compra': formatDate(client.lastPurchaseDate)
|
||||||
|
}));
|
||||||
|
|
||||||
|
exportToCSV(exportData, `rfm_${new Date().toISOString().split('T')[0]}.csv`);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex flex-col xl:flex-row xl:items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold mb-2 text-dark-text">RFM</h1>
|
||||||
|
<p className="text-dark-muted font-medium">Segmentação de clientes por recência, frequência e valor monetário.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col sm:flex-row flex-wrap gap-3 items-start sm:items-center">
|
||||||
|
<button
|
||||||
|
onClick={toggleDemoMode}
|
||||||
|
className={`inline-flex items-center justify-center gap-2 rounded-xl border px-4 py-2.5 text-sm font-bold transition-colors cursor-pointer ${
|
||||||
|
isDemoMode
|
||||||
|
? 'border-amber-500/40 bg-amber-500/10 text-amber-300 hover:bg-amber-500/15'
|
||||||
|
: 'border-dark-border bg-dark-card text-dark-text hover:border-brand-primary'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isDemoMode ? <Database className="h-4 w-4" /> : <Sparkles className="h-4 w-4" />}
|
||||||
|
{isDemoMode ? 'Voltar para dados reais' : 'Ver dados demo'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<DateRangePicker
|
||||||
|
dateRange={dateRange}
|
||||||
|
onChange={setDateRange}
|
||||||
|
refreshInterval={refreshInterval}
|
||||||
|
setRefreshInterval={setRefreshInterval}
|
||||||
|
onManualRefresh={() => void loadRfm(dateRange)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isDemoMode && (
|
||||||
|
<div className="rounded-2xl border border-amber-500/30 bg-amber-500/10 px-4 py-3 text-sm font-semibold text-amber-200">
|
||||||
|
Visualização com dados fictícios. Nenhum cliente real ou dado do banco foi alterado.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isDemoMode && isLoading && (
|
||||||
|
<div className="inline-flex items-center gap-2 rounded-xl border border-dark-border bg-dark-card px-3 py-2 text-sm font-semibold text-dark-muted">
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin text-brand-primary" />
|
||||||
|
Atualizando RFM
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||||
|
<div className="bg-dark-card p-6 rounded-2xl border border-dark-border shadow-sm">
|
||||||
|
<p className="text-dark-muted text-sm font-medium mb-1">Clientes Classificados</p>
|
||||||
|
<h3 className="text-3xl font-bold text-dark-text">{clients.length}</h3>
|
||||||
|
</div>
|
||||||
|
<div className="bg-dark-card p-6 rounded-2xl border border-dark-border shadow-sm">
|
||||||
|
<p className="text-dark-muted text-sm font-medium mb-1">Receita no Período</p>
|
||||||
|
<h3 className="text-3xl font-bold text-dark-text">{formatCurrency(totals.totalRevenue)}</h3>
|
||||||
|
</div>
|
||||||
|
<div className="bg-dark-card p-6 rounded-2xl border border-dark-border shadow-sm">
|
||||||
|
<p className="text-dark-muted text-sm font-medium mb-1">Segmentos Ativos</p>
|
||||||
|
<h3 className="text-3xl font-bold text-dark-text">{totals.activeSegments}</h3>
|
||||||
|
<p className="mt-1 text-xs font-semibold text-dark-muted">
|
||||||
|
Principal: {totals.topSegment?.label || 'N/A'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 xl:grid-cols-[minmax(0,1fr)_340px] gap-6">
|
||||||
|
<section className="bg-dark-card p-5 rounded-2xl border border-dark-border shadow-sm">
|
||||||
|
<div className="mb-4 flex flex-col gap-3 lg:flex-row lg:items-end lg:justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-bold text-dark-text">Matriz RFM</h2>
|
||||||
|
<p className="text-sm font-medium text-dark-muted">Distribuição dos clientes por recência e valor.</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-xs font-semibold text-dark-muted">
|
||||||
|
<span>Menor prioridade</span>
|
||||||
|
<span className="h-2 w-12 rounded-full bg-gradient-to-r from-rose-500 via-amber-400 to-emerald-500" />
|
||||||
|
<span>Maior prioridade</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<div className="grid min-w-[720px] grid-cols-[104px_repeat(3,minmax(0,1fr))] gap-2">
|
||||||
|
<div className="flex items-center justify-center rounded-xl border border-dark-border bg-dark-input/60 px-3 py-2.5 text-center">
|
||||||
|
<div>
|
||||||
|
<p className="text-[10px] font-bold uppercase tracking-wider text-dark-muted">Eixos</p>
|
||||||
|
<p className="text-xs font-semibold text-dark-text">Recência / Valor</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{[1, 2, 3].map(valueScore => (
|
||||||
|
<div key={valueScore} className="rounded-xl border border-dark-border bg-dark-input/70 px-3 py-2.5 text-center">
|
||||||
|
<p className="text-[10px] font-bold uppercase tracking-wider text-dark-muted">Valor {valueScore}</p>
|
||||||
|
<p className="text-xs font-bold text-dark-text">{scoreLabel(valueScore)}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{[3, 2, 1].map(recencyScore => (
|
||||||
|
<div key={recencyScore} className="contents">
|
||||||
|
<div className="flex min-h-28 items-center justify-center rounded-xl border border-dark-border bg-dark-input/70 px-2.5 text-center">
|
||||||
|
<div>
|
||||||
|
<p className="text-[10px] font-bold uppercase tracking-wider text-dark-muted">Rec. {recencyScore}</p>
|
||||||
|
<p className="text-xs font-bold text-dark-text">{scoreLabel(recencyScore)}</p>
|
||||||
|
<p className="mt-1 text-[10px] font-semibold text-dark-muted">
|
||||||
|
{recencyScore === 3 ? 'Compra recente' : recencyScore === 2 ? 'Intermediário' : 'Distante'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{[1, 2, 3].map(valueScore => {
|
||||||
|
const segment = segmentForCell(segments, recencyScore, valueScore);
|
||||||
|
const style = segmentStyles[segment?.key || 'lost'];
|
||||||
|
const isActive = segmentFilter === segment?.key;
|
||||||
|
const intensity = segment?.count ? 'opacity-100' : 'opacity-75';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={`${recencyScore}-${valueScore}`}
|
||||||
|
onClick={() => {
|
||||||
|
setSegmentFilter(segment?.key || 'all');
|
||||||
|
setSelectedSegmentKey(segment?.key || '');
|
||||||
|
}}
|
||||||
|
className={`group min-h-28 rounded-xl border p-3 text-left transition-all hover:-translate-y-0.5 hover:border-brand-primary cursor-pointer ${
|
||||||
|
isActive ? 'border-brand-primary ring-1 ring-brand-primary/50' : 'border-dark-border'
|
||||||
|
} ${style.bg} ${intensity}`}
|
||||||
|
>
|
||||||
|
<div className="flex h-full flex-col justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<div className="mb-2 flex items-start justify-between gap-2">
|
||||||
|
<span className={`h-2 w-8 rounded-full ${style.accent}`} />
|
||||||
|
<span className="rounded-md border border-dark-border bg-black/10 px-1.5 py-0.5 text-[10px] font-bold text-dark-muted">
|
||||||
|
R{recencyScore} V{valueScore}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm font-bold text-dark-text">{segment?.label}</p>
|
||||||
|
<p className="mt-1 truncate text-xs font-medium text-dark-muted">
|
||||||
|
{segmentDescriptions[segment?.key || 'lost']}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<div>
|
||||||
|
<p className="text-[10px] font-bold uppercase tracking-wider text-dark-muted">Clientes</p>
|
||||||
|
<p className={`text-xl font-bold leading-none ${style.text}`}>{segment?.count || 0}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-[10px] font-bold uppercase tracking-wider text-dark-muted">Receita</p>
|
||||||
|
<p className="truncate text-xs font-bold text-dark-text">{formatCurrency(segment?.totalRevenue || 0)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="bg-dark-card p-6 rounded-2xl border border-dark-border shadow-sm">
|
||||||
|
<h2 className="mb-5 text-lg font-bold text-dark-text">Segmentos</h2>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{segments.map(segment => {
|
||||||
|
const style = segmentStyles[segment.key] || segmentStyles.lost;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={segment.key}
|
||||||
|
onClick={() => {
|
||||||
|
setSegmentFilter(segment.key);
|
||||||
|
setSelectedSegmentKey(segment.key);
|
||||||
|
}}
|
||||||
|
className={`w-full rounded-xl border px-4 py-3 text-left transition-colors cursor-pointer ${
|
||||||
|
segmentFilter === segment.key ? 'border-brand-primary bg-dark-input' : 'border-dark-border bg-transparent hover:border-brand-primary'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div className="flex min-w-0 items-center gap-3">
|
||||||
|
<span className={`h-2.5 w-2.5 shrink-0 rounded-full ${style.accent}`} />
|
||||||
|
<span className="truncate text-sm font-bold text-dark-text">{segment.label}</span>
|
||||||
|
</div>
|
||||||
|
<span className={`text-sm font-bold ${style.text}`}>{segment.count}</span>
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-xs font-semibold text-dark-muted">
|
||||||
|
{(segmentInsights[segment.key]?.customerPercent || 0).toFixed(1)}% clientes · {formatCurrency(segment.totalRevenue)}
|
||||||
|
</p>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedSegment && selectedSegmentInsights && (
|
||||||
|
<section className={`rounded-2xl border border-dark-border ${selectedSegmentStyle.bg} p-4 shadow-sm`}>
|
||||||
|
<div className="flex flex-col gap-4 xl:flex-row xl:items-center xl:justify-between">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="mb-1 flex items-center gap-2">
|
||||||
|
<span className={`h-2.5 w-2.5 rounded-full ${selectedSegmentStyle.accent}`} />
|
||||||
|
<p className={`text-xs font-bold uppercase tracking-widest ${selectedSegmentStyle.text}`}>Segmento selecionado</p>
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-bold text-dark-text">{selectedSegment.label}</h3>
|
||||||
|
<p className="mt-1 text-sm font-semibold text-dark-muted">{segmentActions[selectedSegment.key]}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4 xl:w-[560px]">
|
||||||
|
<div className="rounded-xl border border-dark-border bg-black/10 p-3">
|
||||||
|
<p className="text-[10px] font-bold uppercase tracking-wider text-dark-muted">Clientes</p>
|
||||||
|
<p className="mt-1 text-base font-bold text-dark-text">{selectedSegmentInsights.customerPercent.toFixed(1)}%</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-xl border border-dark-border bg-black/10 p-3">
|
||||||
|
<p className="text-[10px] font-bold uppercase tracking-wider text-dark-muted">Receita</p>
|
||||||
|
<p className="mt-1 text-base font-bold text-dark-text">{selectedSegmentInsights.revenuePercent.toFixed(1)}%</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-xl border border-dark-border bg-black/10 p-3">
|
||||||
|
<p className="text-[10px] font-bold uppercase tracking-wider text-dark-muted">Ticket</p>
|
||||||
|
<p className="mt-1 text-base font-bold text-dark-text">{formatCurrency(selectedSegmentInsights.averageTicket)}</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-xl border border-dark-border bg-black/10 p-3">
|
||||||
|
<p className="text-[10px] font-bold uppercase tracking-wider text-dark-muted">Recência</p>
|
||||||
|
<p className="mt-1 text-base font-bold text-dark-text">{selectedSegmentInsights.averageRecencyDays.toFixed(0)} dias</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<section className="bg-dark-card border border-dark-border rounded-2xl overflow-hidden shadow-sm">
|
||||||
|
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-3 border-b border-dark-border p-4">
|
||||||
|
<div className="flex flex-col sm:flex-row gap-3">
|
||||||
|
<div className="relative">
|
||||||
|
<Filter className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-dark-muted" />
|
||||||
|
<select
|
||||||
|
value={segmentFilter}
|
||||||
|
onChange={(event) => setSegmentFilter(event.target.value)}
|
||||||
|
className="appearance-none bg-dark-input border border-dark-border text-dark-text text-sm rounded-xl pl-9 pr-8 py-2.5 focus:outline-none focus:border-brand-primary transition-colors shadow-sm cursor-pointer"
|
||||||
|
>
|
||||||
|
<option value="all">Todos os segmentos</option>
|
||||||
|
{segments.map(segment => (
|
||||||
|
<option key={segment.key} value={segment.key}>{segment.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-3 top-1/2 h-5 w-5 -translate-y-1/2 text-dark-muted" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Buscar cliente..."
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(event) => setSearchTerm(event.target.value)}
|
||||||
|
className="w-full sm:w-72 bg-dark-input border border-dark-border text-dark-text rounded-xl pl-10 pr-4 py-2.5 focus:outline-none focus:border-brand-primary hover:border-brand-primary transition-colors shadow-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleExport}
|
||||||
|
disabled={!filteredClients.length}
|
||||||
|
className="flex items-center justify-center gap-2 bg-dark-input border border-dark-border px-4 py-2.5 rounded-xl shadow-sm hover:border-brand-primary transition-colors text-sm font-medium text-dark-text cursor-pointer disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
title="Exportar para CSV"
|
||||||
|
>
|
||||||
|
<Download size={16} className="text-brand-primary" />
|
||||||
|
Exportar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-left text-sm">
|
||||||
|
<thead className="bg-dark-header border-b border-dark-border text-dark-muted">
|
||||||
|
<tr>
|
||||||
|
<th className="px-6 py-4 font-bold uppercase tracking-wider text-[10px]">Cliente</th>
|
||||||
|
<th className="px-6 py-4 font-bold uppercase tracking-wider text-[10px]">Segmento</th>
|
||||||
|
<th className="px-6 py-4 font-bold uppercase tracking-wider text-[10px]">RFM</th>
|
||||||
|
<th className="px-6 py-4 font-bold uppercase tracking-wider text-[10px]">Última Compra</th>
|
||||||
|
<th className="px-6 py-4 font-bold uppercase tracking-wider text-[10px]">Pedidos</th>
|
||||||
|
<th className="px-6 py-4 font-bold uppercase tracking-wider text-[10px]">Ticket Médio</th>
|
||||||
|
<th className="px-6 py-4 font-bold uppercase tracking-wider text-[10px]">Total Gasto</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-dark-border">
|
||||||
|
{filteredClients.map((client: RfmClient) => {
|
||||||
|
const style = segmentStyles[client.segmentKey] || segmentStyles.lost;
|
||||||
|
return (
|
||||||
|
<tr key={client.phone} className="hover:bg-dark-input/50 transition-colors">
|
||||||
|
<td className="px-6 py-3">
|
||||||
|
{isDemoMode ? (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="flex h-9 w-9 items-center justify-center rounded-xl bg-dark-input text-dark-muted">
|
||||||
|
<Users className="h-4 w-4" />
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
<span className="block font-bold text-dark-text">{client.name}</span>
|
||||||
|
<span className="block text-xs font-medium text-dark-muted">{client.phone || 'N/A'}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Link to={`/clients/${encodeURIComponent(client.name)}`} className="flex items-center gap-3 hover:text-brand-primary transition-colors">
|
||||||
|
<span className="flex h-9 w-9 items-center justify-center rounded-xl bg-dark-input text-dark-muted">
|
||||||
|
<Users className="h-4 w-4" />
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
<span className="block font-bold text-dark-text">{client.name}</span>
|
||||||
|
<span className="block text-xs font-medium text-dark-muted">{client.phone || 'N/A'}</span>
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-3">
|
||||||
|
<span className={`inline-flex items-center gap-2 rounded-full px-3 py-1 text-xs font-bold ${style.bg} ${style.text}`}>
|
||||||
|
<span className={`h-2 w-2 rounded-full ${style.accent}`} />
|
||||||
|
{client.segmentLabel}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<ScoreBadge value={client.recencyScore} />
|
||||||
|
<ScoreBadge value={client.frequencyScore} />
|
||||||
|
<ScoreBadge value={client.monetaryScore} />
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-3 text-dark-muted font-medium">
|
||||||
|
{formatDate(client.lastPurchaseDate)}
|
||||||
|
<span className="block text-xs">{client.recencyDays} dias</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-3 text-dark-text font-bold">{client.frequency}</td>
|
||||||
|
<td className="px-6 py-3 text-dark-text font-bold">{formatCurrency(client.frequency ? client.monetary / client.frequency : 0)}</td>
|
||||||
|
<td className="px-6 py-3 text-brand-primary font-bold">{formatCurrency(client.monetary)}</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!filteredClients.length && (
|
||||||
|
<div className="p-8 text-center text-sm font-semibold text-dark-muted">
|
||||||
|
Nenhum cliente encontrado.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Rfm;
|
||||||
38
src/types.ts
38
src/types.ts
@@ -40,6 +40,44 @@ export interface DashboardAnalytics {
|
|||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface RfmClient {
|
||||||
|
name: string;
|
||||||
|
phone: string;
|
||||||
|
monetary: number;
|
||||||
|
frequency: number;
|
||||||
|
quantityPurchased: number;
|
||||||
|
lastPurchaseDate: string;
|
||||||
|
recencyDays: number;
|
||||||
|
recencyScore: 1 | 2 | 3;
|
||||||
|
frequencyScore: 1 | 2 | 3;
|
||||||
|
monetaryScore: 1 | 2 | 3;
|
||||||
|
valueScore: 1 | 2 | 3;
|
||||||
|
rfmScore: string;
|
||||||
|
segmentKey: string;
|
||||||
|
segmentLabel: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RfmSegment {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
count: number;
|
||||||
|
totalRevenue: number;
|
||||||
|
averageRevenue: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RfmAnalytics {
|
||||||
|
range: {
|
||||||
|
start: string | null;
|
||||||
|
end: string | null;
|
||||||
|
};
|
||||||
|
clients: RfmClient[];
|
||||||
|
segments: RfmSegment[];
|
||||||
|
matrix: {
|
||||||
|
recencyScores: number[];
|
||||||
|
valueScores: number[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export type CampaignStatus = 'pending' | 'processing' | 'sent' | 'failed' | 'skipped';
|
export type CampaignStatus = 'pending' | 'processing' | 'sent' | 'failed' | 'skipped';
|
||||||
|
|
||||||
export interface CampaignQueueItem {
|
export interface CampaignQueueItem {
|
||||||
|
|||||||
Reference in New Issue
Block a user