Optimize client details with opaque tokens
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m20s
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m20s
This commit is contained in:
@@ -97,6 +97,15 @@ const initDB = async () => {
|
|||||||
);
|
);
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
await pool.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS client_identity_tokens (
|
||||||
|
customer_key TEXT PRIMARY KEY,
|
||||||
|
token TEXT NOT NULL UNIQUE,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
await pool.query(`
|
await pool.query(`
|
||||||
ALTER TABLE app_users
|
ALTER TABLE app_users
|
||||||
ALTER COLUMN created_at TYPE TIMESTAMPTZ USING created_at AT TIME ZONE 'America/Sao_Paulo',
|
ALTER COLUMN created_at TYPE TIMESTAMPTZ USING created_at AT TIME ZONE 'America/Sao_Paulo',
|
||||||
@@ -140,6 +149,13 @@ 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_customer_key_date
|
||||||
|
ON orders (
|
||||||
|
(COALESCE(NULLIF(cliente_fone, ''), 'name:' || COALESCE(NULLIF(cliente_nome, ''), 'Cliente Desconhecido'))),
|
||||||
|
data_pedido_date
|
||||||
|
);
|
||||||
|
`);
|
||||||
await pool.query(`CREATE INDEX IF NOT EXISTS idx_orders_produto_id ON orders (produto_id);`);
|
await pool.query(`CREATE INDEX IF NOT EXISTS idx_orders_produto_id ON orders (produto_id);`);
|
||||||
await pool.query(`CREATE INDEX IF NOT EXISTS idx_orders_data_pedido_date ON orders (data_pedido_date);`);
|
await pool.query(`CREATE INDEX IF NOT EXISTS idx_orders_data_pedido_date ON orders (data_pedido_date);`);
|
||||||
await pool.query(`CREATE INDEX IF NOT EXISTS idx_app_users_email ON app_users (LOWER(email));`);
|
await pool.query(`CREATE INDEX IF NOT EXISTS idx_app_users_email ON app_users (LOWER(email));`);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ const express = require('express');
|
|||||||
const { verifyToken } = require('../auth');
|
const { verifyToken } = require('../auth');
|
||||||
const {
|
const {
|
||||||
getClientAnalytics,
|
getClientAnalytics,
|
||||||
|
getClientDetailsAnalytics,
|
||||||
getDashboardAnalytics,
|
getDashboardAnalytics,
|
||||||
getProductAnalytics,
|
getProductAnalytics,
|
||||||
getRfmAnalytics
|
getRfmAnalytics
|
||||||
@@ -41,6 +42,21 @@ router.get('/analytics/clients', verifyToken, async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
router.get('/analytics/clients/:clientToken/details', verifyToken, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const details = await getClientDetailsAnalytics(req.params.clientToken, getRange(req.query));
|
||||||
|
if (!details) {
|
||||||
|
res.status(404).json({ error: 'Client not found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json(details);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching client details analytics:', error);
|
||||||
|
res.status(500).json({ error: 'Internal Server Error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
router.get('/analytics/rfm', verifyToken, async (req, res) => {
|
router.get('/analytics/rfm', verifyToken, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
res.json(await getRfmAnalytics(getRange(req.query)));
|
res.json(await getRfmAnalytics(getRange(req.query)));
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
const crypto = require('node:crypto');
|
||||||
const { pool } = require('../db');
|
const { pool } = require('../db');
|
||||||
|
|
||||||
const RFM_QUERY_TIMEOUT_MS = 15000;
|
const RFM_QUERY_TIMEOUT_MS = 15000;
|
||||||
@@ -5,6 +6,7 @@ const RECENT_MAX_DAYS = 60;
|
|||||||
const COOLING_MAX_DAYS = 180;
|
const COOLING_MAX_DAYS = 180;
|
||||||
const LOST_MIN_DAYS = 366;
|
const LOST_MIN_DAYS = 366;
|
||||||
const FREQUENCY_MEDIUM_MAX_ORDERS = 4;
|
const FREQUENCY_MEDIUM_MAX_ORDERS = 4;
|
||||||
|
const CLIENT_TOKEN_VERSION = 'v1';
|
||||||
const SIZE_SUFFIX_SQL_PATTERN = '\\s+-\\s+(?:(?:PP|P|M|G|GG|XG|XGG|EG|EGG|EXG|U|UNICO|ÚNICO|\\d{2})(?:/(?:PP|P|M|G|GG|XG|XGG|EG|EGG|EXG|U|UNICO|ÚNICO|\\d{2}))*)$';
|
const SIZE_SUFFIX_SQL_PATTERN = '\\s+-\\s+(?:(?:PP|P|M|G|GG|XG|XGG|EG|EGG|EXG|U|UNICO|ÚNICO|\\d{2})(?:/(?:PP|P|M|G|GG|XG|XGG|EG|EGG|EXG|U|UNICO|ÚNICO|\\d{2}))*)$';
|
||||||
const PRODUCT_NAME_SQL = `
|
const PRODUCT_NAME_SQL = `
|
||||||
CASE
|
CASE
|
||||||
@@ -14,6 +16,92 @@ const PRODUCT_NAME_SQL = `
|
|||||||
`;
|
`;
|
||||||
const CUSTOMER_KEY_SQL = "COALESCE(NULLIF(cliente_fone, ''), 'name:' || COALESCE(NULLIF(cliente_nome, ''), 'Cliente Desconhecido'))";
|
const CUSTOMER_KEY_SQL = "COALESCE(NULLIF(cliente_fone, ''), 'name:' || COALESCE(NULLIF(cliente_nome, ''), 'Cliente Desconhecido'))";
|
||||||
|
|
||||||
|
const getClientTokenSecret = () => (
|
||||||
|
process.env.CLIENT_TOKEN_SECRET ||
|
||||||
|
process.env.JWT_SECRET ||
|
||||||
|
process.env.API_KEY ||
|
||||||
|
'nexstar-client-token-development-secret'
|
||||||
|
);
|
||||||
|
|
||||||
|
let clientTokenSecretCache = null;
|
||||||
|
const getClientTokenHashSecret = () => {
|
||||||
|
const secret = getClientTokenSecret();
|
||||||
|
if (clientTokenSecretCache?.secret === secret) return clientTokenSecretCache.hashSecret;
|
||||||
|
|
||||||
|
clientTokenSecretCache = {
|
||||||
|
secret,
|
||||||
|
hashSecret: crypto.createHash('sha256').update(`${secret}:client-token`).digest('base64url')
|
||||||
|
};
|
||||||
|
return clientTokenSecretCache.hashSecret;
|
||||||
|
};
|
||||||
|
|
||||||
|
const createClientToken = (customerKey) => {
|
||||||
|
if (!customerKey) return '';
|
||||||
|
|
||||||
|
const normalizedCustomerKey = String(customerKey);
|
||||||
|
const digest = crypto
|
||||||
|
.createHash('sha256')
|
||||||
|
.update(getClientTokenHashSecret())
|
||||||
|
.update(':')
|
||||||
|
.update(normalizedCustomerKey)
|
||||||
|
.digest('base64url');
|
||||||
|
|
||||||
|
return `${CLIENT_TOKEN_VERSION}.${digest}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isClientToken = (clientToken) => {
|
||||||
|
const parts = String(clientToken || '').split('.');
|
||||||
|
return parts.length === 2 && parts[0] === CLIENT_TOKEN_VERSION && /^[A-Za-z0-9_-]+$/.test(parts[1] || '');
|
||||||
|
};
|
||||||
|
|
||||||
|
const persistClientTokenMappings = async (clients, queryable = pool) => {
|
||||||
|
const mappingsByCustomerKey = new Map();
|
||||||
|
|
||||||
|
clients.forEach(client => {
|
||||||
|
const customerKey = client.customerKey || client.customer_key;
|
||||||
|
const clientToken = client.clientToken || createClientToken(customerKey);
|
||||||
|
if (customerKey && clientToken) {
|
||||||
|
mappingsByCustomerKey.set(customerKey, clientToken);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const mappings = [...mappingsByCustomerKey.entries()];
|
||||||
|
const chunkSize = 5000;
|
||||||
|
|
||||||
|
for (let index = 0; index < mappings.length; index += chunkSize) {
|
||||||
|
const chunk = mappings.slice(index, index + chunkSize);
|
||||||
|
const params = [];
|
||||||
|
const values = chunk.map(([customerKey, clientToken], chunkIndex) => {
|
||||||
|
params.push(customerKey, clientToken);
|
||||||
|
const offset = chunkIndex * 2;
|
||||||
|
return `($${offset + 1}, $${offset + 2})`;
|
||||||
|
});
|
||||||
|
|
||||||
|
await queryable.query(`
|
||||||
|
INSERT INTO client_identity_tokens (customer_key, token)
|
||||||
|
VALUES ${values.join(', ')}
|
||||||
|
ON CONFLICT (customer_key)
|
||||||
|
DO UPDATE SET
|
||||||
|
token = EXCLUDED.token,
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE client_identity_tokens.token IS DISTINCT FROM EXCLUDED.token;
|
||||||
|
`, params);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolveClientToken = async (clientToken) => {
|
||||||
|
if (!isClientToken(clientToken)) return null;
|
||||||
|
|
||||||
|
const result = await pool.query(`
|
||||||
|
SELECT customer_key
|
||||||
|
FROM client_identity_tokens
|
||||||
|
WHERE token = $1
|
||||||
|
LIMIT 1;
|
||||||
|
`, [clientToken]);
|
||||||
|
|
||||||
|
return result.rows[0]?.customer_key || null;
|
||||||
|
};
|
||||||
|
|
||||||
const normalizeDateParam = (value) => {
|
const normalizeDateParam = (value) => {
|
||||||
if (!value) return null;
|
if (!value) return null;
|
||||||
|
|
||||||
@@ -356,8 +444,9 @@ const getClientAnalytics = async (range = {}) => {
|
|||||||
ORDER BY total_spent DESC;
|
ORDER BY total_spent DESC;
|
||||||
`, params);
|
`, params);
|
||||||
|
|
||||||
return result.rows.map(row => ({
|
const clients = result.rows.map(row => ({
|
||||||
customerKey: row.customer_key,
|
customerKey: row.customer_key,
|
||||||
|
clientToken: createClientToken(row.customer_key),
|
||||||
name: row.name,
|
name: row.name,
|
||||||
phone: row.phone || '',
|
phone: row.phone || '',
|
||||||
quantityPurchased: toNumber(row.quantity_purchased),
|
quantityPurchased: toNumber(row.quantity_purchased),
|
||||||
@@ -365,6 +454,140 @@ const getClientAnalytics = async (range = {}) => {
|
|||||||
orderCount: toNumber(row.order_count),
|
orderCount: toNumber(row.order_count),
|
||||||
lastPurchaseDate: row.last_purchase_date
|
lastPurchaseDate: row.last_purchase_date
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
await persistClientTokenMappings(clients);
|
||||||
|
return clients;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getOrderGroupKey = (row) => (
|
||||||
|
row.pedido_id ||
|
||||||
|
`${row.data_pedido || getDateOnly(row.data_pedido_date) || ''}_${row.valor_pedido || 0}`
|
||||||
|
);
|
||||||
|
|
||||||
|
const getClientDetailsAnalytics = async (clientToken, range = {}) => {
|
||||||
|
const customerKey = await resolveClientToken(clientToken);
|
||||||
|
if (!customerKey) return null;
|
||||||
|
|
||||||
|
const normalizedStart = normalizeDateParam(range.start);
|
||||||
|
const normalizedEnd = normalizeDateParam(range.end);
|
||||||
|
const periodParams = [customerKey];
|
||||||
|
const periodFilters = [
|
||||||
|
`${CUSTOMER_KEY_SQL} = $1`,
|
||||||
|
'data_pedido_date IS NOT NULL'
|
||||||
|
];
|
||||||
|
|
||||||
|
if (normalizedStart) {
|
||||||
|
periodParams.push(normalizedStart);
|
||||||
|
periodFilters.push(`data_pedido_date >= $${periodParams.length}::date`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalizedEnd) {
|
||||||
|
periodParams.push(normalizedEnd);
|
||||||
|
periodFilters.push(`data_pedido_date <= $${periodParams.length}::date`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [summaryResult, periodResult] = await Promise.all([
|
||||||
|
pool.query(`
|
||||||
|
SELECT
|
||||||
|
MAX(COALESCE(NULLIF(cliente_nome, ''), 'Cliente Desconhecido')) as name,
|
||||||
|
MAX(NULLIF(cliente_fone, '')) as phone,
|
||||||
|
COUNT(DISTINCT COALESCE(NULLIF(pedido_id, ''), data_pedido || '_' || valor_pedido::text))::int as all_time_order_count
|
||||||
|
FROM orders
|
||||||
|
WHERE ${CUSTOMER_KEY_SQL} = $1
|
||||||
|
AND data_pedido_date IS NOT NULL;
|
||||||
|
`, [customerKey]),
|
||||||
|
pool.query(`
|
||||||
|
SELECT
|
||||||
|
cliente_nome,
|
||||||
|
cliente_fone,
|
||||||
|
data_pedido,
|
||||||
|
data_pedido_date,
|
||||||
|
valor_pedido,
|
||||||
|
produto_id,
|
||||||
|
produto_descricao,
|
||||||
|
quantidade,
|
||||||
|
valor_unitario,
|
||||||
|
pedido_id
|
||||||
|
FROM orders
|
||||||
|
WHERE ${periodFilters.join(' AND ')}
|
||||||
|
ORDER BY data_pedido_date DESC, COALESCE(NULLIF(pedido_id, ''), data_pedido || '_' || valor_pedido::text) DESC;
|
||||||
|
`, periodParams)
|
||||||
|
]);
|
||||||
|
|
||||||
|
const summary = summaryResult.rows[0] || {};
|
||||||
|
const allTimeOrderCount = toNumber(summary.all_time_order_count);
|
||||||
|
if (!allTimeOrderCount) return null;
|
||||||
|
|
||||||
|
const groupedOrdersByKey = new Map();
|
||||||
|
const spentByDate = new Map();
|
||||||
|
let periodSpent = 0;
|
||||||
|
let periodItems = 0;
|
||||||
|
|
||||||
|
periodResult.rows.forEach(row => {
|
||||||
|
const itemRevenue = toNumber(row.quantidade) * toNumber(row.valor_unitario);
|
||||||
|
const dateKey = getDateOnly(row.data_pedido_date) || getDateOnly(row.data_pedido) || '';
|
||||||
|
const dateLabel = row.data_pedido || dateKey;
|
||||||
|
const groupKey = getOrderGroupKey(row);
|
||||||
|
|
||||||
|
periodSpent += itemRevenue;
|
||||||
|
periodItems += toNumber(row.quantidade);
|
||||||
|
|
||||||
|
if (dateLabel) {
|
||||||
|
const currentDateSpend = spentByDate.get(dateLabel) || { date: dateLabel, sortDate: dateKey, value: 0 };
|
||||||
|
currentDateSpend.value += itemRevenue;
|
||||||
|
spentByDate.set(dateLabel, currentDateSpend);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!groupedOrdersByKey.has(groupKey)) {
|
||||||
|
groupedOrdersByKey.set(groupKey, {
|
||||||
|
date: dateLabel,
|
||||||
|
sortDate: dateKey,
|
||||||
|
orderId: row.pedido_id || groupKey,
|
||||||
|
orderTotal: 0,
|
||||||
|
items: []
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const group = groupedOrdersByKey.get(groupKey);
|
||||||
|
group.orderTotal += itemRevenue;
|
||||||
|
group.items.push({
|
||||||
|
Nome_Cliente: row.cliente_nome || summary.name || 'Cliente Desconhecido',
|
||||||
|
Data_Pedido: dateLabel,
|
||||||
|
Valor_Pedido: toNumber(row.valor_pedido),
|
||||||
|
ID_Produto: row.produto_id || '',
|
||||||
|
Descricao_Produto: row.produto_descricao || 'Unknown',
|
||||||
|
Quantidade: toNumber(row.quantidade),
|
||||||
|
Valor_Unitario: toNumber(row.valor_unitario),
|
||||||
|
ID_Pedido: row.pedido_id || '',
|
||||||
|
Fone_Cliente: row.cliente_fone || ''
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const groupedOrders = [...groupedOrdersByKey.values()]
|
||||||
|
.sort((a, b) => String(b.sortDate).localeCompare(String(a.sortDate)))
|
||||||
|
.map(({ sortDate, ...group }) => group);
|
||||||
|
const chartData = [...spentByDate.values()]
|
||||||
|
.sort((a, b) => String(a.sortDate).localeCompare(String(b.sortDate)))
|
||||||
|
.map(({ sortDate, ...entry }) => entry);
|
||||||
|
const periodOrderCount = groupedOrders.length;
|
||||||
|
|
||||||
|
return {
|
||||||
|
range: {
|
||||||
|
start: normalizedStart,
|
||||||
|
end: normalizedEnd
|
||||||
|
},
|
||||||
|
clientToken,
|
||||||
|
clientName: summary.name || 'Cliente Desconhecido',
|
||||||
|
clientPhone: summary.phone || '',
|
||||||
|
hasClient: true,
|
||||||
|
allTimeOrderCount,
|
||||||
|
periodSpent,
|
||||||
|
periodAverageTicket: periodOrderCount ? periodSpent / periodOrderCount : 0,
|
||||||
|
periodOrderCount,
|
||||||
|
periodItems,
|
||||||
|
chartData,
|
||||||
|
groupedOrders
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const getRfmAnalytics = async (range = {}) => {
|
const getRfmAnalytics = async (range = {}) => {
|
||||||
@@ -379,6 +602,7 @@ const getRfmAnalytics = async (range = {}) => {
|
|||||||
const recencyEnd = normalizedEnd || new Date().toISOString().slice(0, 10);
|
const recencyEnd = normalizedEnd || new Date().toISOString().slice(0, 10);
|
||||||
const clients = buildRfmClients(clientRows.map(row => ({
|
const clients = buildRfmClients(clientRows.map(row => ({
|
||||||
customerKey: row.customerKey,
|
customerKey: row.customerKey,
|
||||||
|
clientToken: row.clientToken || createClientToken(row.customerKey),
|
||||||
name: row.name,
|
name: row.name,
|
||||||
phone: row.phone || '',
|
phone: row.phone || '',
|
||||||
monetary: row.totalSpent,
|
monetary: row.totalSpent,
|
||||||
@@ -492,6 +716,7 @@ const getRfmAnalytics = async (range = {}) => {
|
|||||||
|
|
||||||
const historyClients = buildRfmClients(historyRows.map(row => ({
|
const historyClients = buildRfmClients(historyRows.map(row => ({
|
||||||
customerKey: row.customer_key,
|
customerKey: row.customer_key,
|
||||||
|
clientToken: createClientToken(row.customer_key),
|
||||||
name: row.name,
|
name: row.name,
|
||||||
phone: row.phone || '',
|
phone: row.phone || '',
|
||||||
monetary: toNumber(row.monetary),
|
monetary: toNumber(row.monetary),
|
||||||
@@ -509,6 +734,7 @@ const getRfmAnalytics = async (range = {}) => {
|
|||||||
if (!taggedClient) {
|
if (!taggedClient) {
|
||||||
const [fallbackClient] = buildRfmClients([{
|
const [fallbackClient] = buildRfmClients([{
|
||||||
customerKey: row.customer_key,
|
customerKey: row.customer_key,
|
||||||
|
clientToken: createClientToken(row.customer_key),
|
||||||
name: row.name,
|
name: row.name,
|
||||||
phone: row.phone || '',
|
phone: row.phone || '',
|
||||||
monetary: toNumber(row.monetary),
|
monetary: toNumber(row.monetary),
|
||||||
@@ -523,6 +749,7 @@ const getRfmAnalytics = async (range = {}) => {
|
|||||||
return {
|
return {
|
||||||
...taggedClient,
|
...taggedClient,
|
||||||
customerKey: row.customer_key,
|
customerKey: row.customer_key,
|
||||||
|
clientToken: taggedClient.clientToken || createClientToken(row.customer_key),
|
||||||
name: row.name,
|
name: row.name,
|
||||||
phone: row.phone || '',
|
phone: row.phone || '',
|
||||||
monetary: toNumber(row.monetary),
|
monetary: toNumber(row.monetary),
|
||||||
@@ -536,6 +763,7 @@ const getRfmAnalytics = async (range = {}) => {
|
|||||||
return b.monetary - a.monetary;
|
return b.monetary - a.monetary;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await persistClientTokenMappings(clients, client);
|
||||||
await client.query('COMMIT');
|
await client.query('COMMIT');
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -562,7 +790,10 @@ module.exports = {
|
|||||||
buildDateFilter,
|
buildDateFilter,
|
||||||
buildRfmClients,
|
buildRfmClients,
|
||||||
buildRfmSegments,
|
buildRfmSegments,
|
||||||
|
createClientToken,
|
||||||
|
isClientToken,
|
||||||
getFrequencyScore,
|
getFrequencyScore,
|
||||||
|
getClientDetailsAnalytics,
|
||||||
getPreviousDate,
|
getPreviousDate,
|
||||||
getRecencyScore,
|
getRecencyScore,
|
||||||
getRfmAnalytics,
|
getRfmAnalytics,
|
||||||
|
|||||||
@@ -6,6 +6,10 @@ const {
|
|||||||
buildRfmSegments,
|
buildRfmSegments,
|
||||||
buildDateFilter,
|
buildDateFilter,
|
||||||
getFrequencyScore,
|
getFrequencyScore,
|
||||||
|
createClientToken,
|
||||||
|
isClientToken,
|
||||||
|
getClientAnalytics,
|
||||||
|
getClientDetailsAnalytics,
|
||||||
getPreviousDate,
|
getPreviousDate,
|
||||||
getRecencyScore,
|
getRecencyScore,
|
||||||
getRfmAnalytics,
|
getRfmAnalytics,
|
||||||
@@ -91,6 +95,21 @@ test('getPreviousDate returns the calendar day before an ISO date', () => {
|
|||||||
assert.equal(getPreviousDate('invalid'), null);
|
assert.equal(getPreviousDate('invalid'), null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('client tokens are opaque and stable for customer keys', () => {
|
||||||
|
const phoneKey = '(16) 99103-6131';
|
||||||
|
const nameKey = 'name:Cliente Sem Fone';
|
||||||
|
const phoneToken = createClientToken(phoneKey);
|
||||||
|
const nameToken = createClientToken(nameKey);
|
||||||
|
|
||||||
|
assert.ok(isClientToken(phoneToken));
|
||||||
|
assert.ok(isClientToken(nameToken));
|
||||||
|
assert.equal(createClientToken(phoneKey), phoneToken);
|
||||||
|
assert.notEqual(createClientToken('name:Marcela Abreu'), phoneToken);
|
||||||
|
assert.doesNotMatch(phoneToken, /99103|6131|\(16\)/);
|
||||||
|
assert.doesNotMatch(nameToken, /Cliente|Sem|Fone/);
|
||||||
|
assert.equal(isClientToken('invalid-token'), false);
|
||||||
|
});
|
||||||
|
|
||||||
test('scoreTertile scores higher values higher by default', () => {
|
test('scoreTertile scores higher values higher by default', () => {
|
||||||
const values = [10, 20, 30, 40, 50];
|
const values = [10, 20, 30, 40, 50];
|
||||||
|
|
||||||
@@ -311,6 +330,154 @@ test('buildRfmClients applies lifecycle protections to new, hibernating, at-risk
|
|||||||
assert.equal(byKey.get('lost').rfmScore, '113');
|
assert.equal(byKey.get('lost').rfmScore, '113');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('getClientAnalytics returns opaque client tokens', async () => {
|
||||||
|
const originalQuery = pool.query;
|
||||||
|
const calls = [];
|
||||||
|
|
||||||
|
pool.query = async (sql, params = []) => {
|
||||||
|
calls.push({ sql, params });
|
||||||
|
|
||||||
|
if (sql.includes('INSERT INTO client_identity_tokens')) {
|
||||||
|
return { rows: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
rows: [
|
||||||
|
{
|
||||||
|
customer_key: '(16) 99103-6131',
|
||||||
|
name: 'Marcela Abreu',
|
||||||
|
phone: '(16) 99103-6131',
|
||||||
|
quantity_purchased: 10,
|
||||||
|
total_spent: 500,
|
||||||
|
order_count: 2,
|
||||||
|
last_purchase_date: '2026-06-15'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
customer_key: 'name:Cliente Sem Fone',
|
||||||
|
name: 'Cliente Sem Fone',
|
||||||
|
phone: null,
|
||||||
|
quantity_purchased: 1,
|
||||||
|
total_spent: 50,
|
||||||
|
order_count: 1,
|
||||||
|
last_purchase_date: '2026-06-10'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const clients = await getClientAnalytics({ start: '2026-06-01', end: '2026-06-15' });
|
||||||
|
|
||||||
|
assert.equal(calls.length, 2);
|
||||||
|
assert.equal(clients.length, 2);
|
||||||
|
assert.match(calls[1].sql, /INSERT INTO client_identity_tokens/);
|
||||||
|
assert.deepEqual(calls[1].params, [
|
||||||
|
'(16) 99103-6131',
|
||||||
|
clients[0].clientToken,
|
||||||
|
'name:Cliente Sem Fone',
|
||||||
|
clients[1].clientToken
|
||||||
|
]);
|
||||||
|
assert.doesNotMatch(clients[0].clientToken, /99103|6131|Marcela/);
|
||||||
|
assert.doesNotMatch(clients[1].clientToken, /Cliente|Fone/);
|
||||||
|
assert.ok(isClientToken(clients[0].clientToken));
|
||||||
|
assert.ok(isClientToken(clients[1].clientToken));
|
||||||
|
} finally {
|
||||||
|
pool.query = originalQuery;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getClientDetailsAnalytics fetches only the tokenized client and period rows', async () => {
|
||||||
|
const originalQuery = pool.query;
|
||||||
|
const calls = [];
|
||||||
|
const clientToken = createClientToken('name:Cliente Sem Fone');
|
||||||
|
|
||||||
|
pool.query = async (sql, params = []) => {
|
||||||
|
calls.push({ sql, params });
|
||||||
|
|
||||||
|
if (sql.includes('FROM client_identity_tokens')) {
|
||||||
|
return { rows: [{ customer_key: 'name:Cliente Sem Fone' }] };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sql.includes('all_time_order_count')) {
|
||||||
|
return {
|
||||||
|
rows: [{
|
||||||
|
name: 'Cliente Sem Fone',
|
||||||
|
phone: null,
|
||||||
|
all_time_order_count: 3
|
||||||
|
}]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
rows: [
|
||||||
|
{
|
||||||
|
cliente_nome: 'Cliente Sem Fone',
|
||||||
|
cliente_fone: null,
|
||||||
|
data_pedido: '10-06-2026',
|
||||||
|
data_pedido_date: '2026-06-10',
|
||||||
|
valor_pedido: 25,
|
||||||
|
produto_id: 'produto-1',
|
||||||
|
produto_descricao: 'Produto A',
|
||||||
|
quantidade: 2,
|
||||||
|
valor_unitario: 10,
|
||||||
|
pedido_id: 'pedido-1'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
cliente_nome: 'Cliente Sem Fone',
|
||||||
|
cliente_fone: null,
|
||||||
|
data_pedido: '10-06-2026',
|
||||||
|
data_pedido_date: '2026-06-10',
|
||||||
|
valor_pedido: 25,
|
||||||
|
produto_id: 'produto-2',
|
||||||
|
produto_descricao: 'Produto B',
|
||||||
|
quantidade: 1,
|
||||||
|
valor_unitario: 5,
|
||||||
|
pedido_id: 'pedido-1'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const details = await getClientDetailsAnalytics(clientToken, { start: '2026-06-01', end: '2026-06-15' });
|
||||||
|
|
||||||
|
assert.equal(calls.length, 3);
|
||||||
|
assert.match(calls[0].sql, /FROM client_identity_tokens/);
|
||||||
|
assert.deepEqual(calls[0].params, [clientToken]);
|
||||||
|
assert.match(calls[1].sql, /WHERE COALESCE\(NULLIF\(cliente_fone, ''\), 'name:' \|\| COALESCE\(NULLIF\(cliente_nome, ''\), 'Cliente Desconhecido'\)\) = \$1/);
|
||||||
|
assert.deepEqual(calls[1].params, ['name:Cliente Sem Fone']);
|
||||||
|
assert.match(calls[2].sql, /data_pedido_date >= \$2::date/);
|
||||||
|
assert.match(calls[2].sql, /data_pedido_date <= \$3::date/);
|
||||||
|
assert.deepEqual(calls[2].params, ['name:Cliente Sem Fone', '2026-06-01', '2026-06-15']);
|
||||||
|
assert.equal(details.clientName, 'Cliente Sem Fone');
|
||||||
|
assert.equal(details.clientPhone, '');
|
||||||
|
assert.equal(details.allTimeOrderCount, 3);
|
||||||
|
assert.equal(details.periodSpent, 25);
|
||||||
|
assert.equal(details.periodItems, 3);
|
||||||
|
assert.equal(details.periodOrderCount, 1);
|
||||||
|
assert.equal(details.periodAverageTicket, 25);
|
||||||
|
assert.deepEqual(details.chartData, [{ date: '10-06-2026', value: 25 }]);
|
||||||
|
assert.equal(details.groupedOrders.length, 1);
|
||||||
|
assert.equal(details.groupedOrders[0].orderTotal, 25);
|
||||||
|
assert.equal(details.groupedOrders[0].items.length, 2);
|
||||||
|
} finally {
|
||||||
|
pool.query = originalQuery;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getClientDetailsAnalytics rejects invalid client tokens before querying', async () => {
|
||||||
|
const originalQuery = pool.query;
|
||||||
|
pool.query = async () => {
|
||||||
|
throw new Error('invalid client token should not query');
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
assert.equal(await getClientDetailsAnalytics('not-a-token', { start: '2026-06-01', end: '2026-06-15' }), null);
|
||||||
|
} finally {
|
||||||
|
pool.query = originalQuery;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('getRfmAnalytics classifies period buyers by history through the selected range end', async () => {
|
test('getRfmAnalytics classifies period buyers by history through the selected range end', async () => {
|
||||||
const originalConnect = pool.connect;
|
const originalConnect = pool.connect;
|
||||||
const calls = [];
|
const calls = [];
|
||||||
@@ -452,6 +619,10 @@ test('getRfmAnalytics reuses client aggregate rows as RFV history for all-period
|
|||||||
pool.query = async (sql, params = []) => {
|
pool.query = async (sql, params = []) => {
|
||||||
calls.push({ sql, params });
|
calls.push({ sql, params });
|
||||||
|
|
||||||
|
if (sql.includes('INSERT INTO client_identity_tokens')) {
|
||||||
|
return { rows: [] };
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
rows: [
|
rows: [
|
||||||
{
|
{
|
||||||
@@ -482,11 +653,12 @@ test('getRfmAnalytics reuses client aggregate rows as RFV history for all-period
|
|||||||
try {
|
try {
|
||||||
const result = await getRfmAnalytics({ start: '2000-01-01', end: '2026-06-15' });
|
const result = await getRfmAnalytics({ start: '2000-01-01', end: '2026-06-15' });
|
||||||
|
|
||||||
assert.equal(calls.length, 1);
|
assert.equal(calls.length, 2);
|
||||||
assert.match(calls[0].sql, /GROUP BY customer_key/);
|
assert.match(calls[0].sql, /GROUP BY customer_key/);
|
||||||
assert.doesNotMatch(calls[0].sql, /recency_days/);
|
assert.doesNotMatch(calls[0].sql, /recency_days/);
|
||||||
assert.doesNotMatch(calls[0].sql, /data_pedido_date >=/);
|
assert.doesNotMatch(calls[0].sql, /data_pedido_date >=/);
|
||||||
assert.deepEqual(calls[0].params, ['2026-06-15']);
|
assert.deepEqual(calls[0].params, ['2026-06-15']);
|
||||||
|
assert.match(calls[1].sql, /INSERT INTO client_identity_tokens/);
|
||||||
assert.equal(result.clients.length, 2);
|
assert.equal(result.clients.length, 2);
|
||||||
assert.equal(result.segments.reduce((total, segment) => total + segment.count, 0), 2);
|
assert.equal(result.segments.reduce((total, segment) => total + segment.count, 0), 2);
|
||||||
assert.ok(result.clients.some(client => client.customerKey === 'name:Cliente Sem Fone' && client.phone === ''));
|
assert.ok(result.clients.some(client => client.customerKey === 'name:Cliente Sem Fone' && client.phone === ''));
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ function App() {
|
|||||||
<Route path="products" element={<Products />} />
|
<Route path="products" element={<Products />} />
|
||||||
<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/:customerKey" element={<ClientDetails />} />
|
<Route path="clients/:clientToken" element={<ClientDetails />} />
|
||||||
<Route path="rfm" element={<Rfm />} />
|
<Route path="rfm" element={<Rfm />} />
|
||||||
<Route path="campaigns" element={<Campaigns />} />
|
<Route path="campaigns" element={<Campaigns />} />
|
||||||
<Route path="admin/users" element={<SuperAdminRoute><AdminUsers /></SuperAdminRoute>} />
|
<Route path="admin/users" element={<SuperAdminRoute><AdminUsers /></SuperAdminRoute>} />
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export type ClientSortOption =
|
|||||||
|
|
||||||
export interface ClientSummary {
|
export interface ClientSummary {
|
||||||
customerKey: string;
|
customerKey: string;
|
||||||
|
clientToken: string;
|
||||||
name: string;
|
name: string;
|
||||||
phone: string;
|
phone: string;
|
||||||
totalSpent: number;
|
totalSpent: number;
|
||||||
@@ -98,6 +99,7 @@ const enrichClientsWithRfmType = (
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
...client,
|
...client,
|
||||||
|
clientToken: client.clientToken || client.customerKey,
|
||||||
averageTicket: client.orderCount ? client.totalSpent / client.orderCount : 0,
|
averageTicket: client.orderCount ? client.totalSpent / client.orderCount : 0,
|
||||||
clientType: getClientType(recencyScore, valueScore),
|
clientType: getClientType(recencyScore, valueScore),
|
||||||
rfmScore: `${recencyScore}${frequencyScore}${monetaryScore}`,
|
rfmScore: `${recencyScore}${frequencyScore}${monetaryScore}`,
|
||||||
@@ -147,6 +149,7 @@ export const buildClientsSummary = (
|
|||||||
const normalizedSearch = searchTerm.trim().toLowerCase();
|
const normalizedSearch = searchTerm.trim().toLowerCase();
|
||||||
const clients = enrichClientsWithRfmType(Object.keys(clientMap).map(customerKey => ({
|
const clients = enrichClientsWithRfmType(Object.keys(clientMap).map(customerKey => ({
|
||||||
customerKey,
|
customerKey,
|
||||||
|
clientToken: customerKey,
|
||||||
name: clientMap[customerKey].name,
|
name: clientMap[customerKey].name,
|
||||||
phone: clientMap[customerKey].phone,
|
phone: clientMap[customerKey].phone,
|
||||||
totalSpent: clientMap[customerKey].totalSpent,
|
totalSpent: clientMap[customerKey].totalSpent,
|
||||||
|
|||||||
@@ -7,8 +7,7 @@ import { rangeForLastDays } from '../dateRanges';
|
|||||||
|
|
||||||
const Layout = () => {
|
const Layout = () => {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const needsRawData = location.pathname.startsWith('/products') ||
|
const needsRawData = location.pathname.startsWith('/products');
|
||||||
(location.pathname.startsWith('/clients/') && location.pathname !== '/clients');
|
|
||||||
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(() => {
|
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(() => {
|
||||||
return localStorage.getItem('graph_sidebar_collapsed') === 'true';
|
return localStorage.getItem('graph_sidebar_collapsed') === 'true';
|
||||||
});
|
});
|
||||||
@@ -46,7 +45,7 @@ const Layout = () => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!needsRawData) return;
|
if (!needsRawData) return;
|
||||||
|
|
||||||
// Product pages and client details still depend on raw orders until their API migration is complete.
|
// Product pages still depend on raw orders until their API migration is complete.
|
||||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||||
void loadData(true);
|
void loadData(true);
|
||||||
}, [loadData, needsRawData]);
|
}, [loadData, needsRawData]);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { AuthUser, CampaignPreview, CampaignProcessSummary, CampaignQueueSummary, ClientAnalyticsItem, CreateUserResult, DashboardAnalytics, DateRange, ManagedUser, OrderData, RfmAnalytics, StockData } from './types';
|
import type { AuthUser, CampaignPreview, CampaignProcessSummary, CampaignQueueSummary, ClientAnalyticsItem, ClientDetailsAnalytics, CreateUserResult, DashboardAnalytics, DateRange, ManagedUser, OrderData, RfmAnalytics, StockData } from './types';
|
||||||
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';
|
||||||
@@ -154,6 +154,21 @@ export const fetchClientAnalytics = async (dateRange: DateRange): Promise<Client
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const fetchClientDetailsAnalytics = async (clientToken: string, dateRange: DateRange): Promise<ClientDetailsAnalytics | null> => {
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
start: formatDateParam(dateRange.start),
|
||||||
|
end: formatDateParam(dateRange.end)
|
||||||
|
});
|
||||||
|
const response = await authFetch(`/analytics/clients/${encodeURIComponent(clientToken)}/details?${params.toString()}`);
|
||||||
|
if (!response.ok) return null;
|
||||||
|
return await response.json();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Fetch client details 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,10 @@
|
|||||||
import { useMemo, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useParams, Link, useOutletContext, useSearchParams } from 'react-router-dom';
|
import { useParams, Link, useOutletContext } from 'react-router-dom';
|
||||||
import { ArrowLeft, User, Tag, Package, DollarSign, Clock, Phone, ChevronLeft, ChevronRight, ShoppingBag, ReceiptText } from 'lucide-react';
|
import { ArrowLeft, User, Tag, Package, DollarSign, Clock, Phone, ChevronLeft, ChevronRight, ShoppingBag, ReceiptText } from 'lucide-react';
|
||||||
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
|
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
|
||||||
import DateRangePicker from '../components/DateRangePicker';
|
import DateRangePicker from '../components/DateRangePicker';
|
||||||
import type { DateRange, OrderData } from '../types';
|
import type { ClientDetailsAnalytics, DateRange } from '../types';
|
||||||
import { buildClientDetailsMetrics } from '../analytics/clients';
|
import { fetchClientDetailsAnalytics } from '../dataService';
|
||||||
|
|
||||||
type CustomTooltipProps = {
|
type CustomTooltipProps = {
|
||||||
active?: boolean;
|
active?: boolean;
|
||||||
@@ -27,34 +27,44 @@ const CustomTooltip = ({ active, payload, label }: CustomTooltipProps) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const ClientDetails = () => {
|
const ClientDetails = () => {
|
||||||
const { customerKey } = useParams<{ customerKey: string }>();
|
const { clientToken } = useParams<{ clientToken: string }>();
|
||||||
const decodedCustomerKey = customerKey ? decodeURIComponent(customerKey) : '';
|
const decodedClientToken = clientToken ? decodeURIComponent(clientToken) : '';
|
||||||
const [searchParams] = useSearchParams();
|
const { dateRange, setDateRange } = useOutletContext<{
|
||||||
const requestedName = searchParams.get('name') || '';
|
|
||||||
const { dateRange, setDateRange, ordersData, isDataLoading } = useOutletContext<{
|
|
||||||
dateRange: DateRange,
|
dateRange: DateRange,
|
||||||
setDateRange: (range: DateRange) => void,
|
setDateRange: (range: DateRange) => void
|
||||||
ordersData: OrderData[],
|
|
||||||
isDataLoading: boolean
|
|
||||||
}>();
|
}>();
|
||||||
|
const [details, setDetails] = useState<ClientDetailsAnalytics | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
const [ordersPerPage, setOrdersPerPage] = useState(5);
|
const [ordersPerPage, setOrdersPerPage] = useState(5);
|
||||||
|
|
||||||
const {
|
useEffect(() => {
|
||||||
chartData,
|
let isMounted = true;
|
||||||
groupedOrders,
|
|
||||||
allTimeOrderCount,
|
const loadClientDetails = async () => {
|
||||||
clientName,
|
if (!decodedClientToken) {
|
||||||
clientPhone,
|
if (isMounted) {
|
||||||
hasClient,
|
setDetails(null);
|
||||||
periodAverageTicket,
|
setIsLoading(false);
|
||||||
periodItems,
|
}
|
||||||
periodOrderCount,
|
return;
|
||||||
periodSpent
|
}
|
||||||
} = useMemo(() => {
|
|
||||||
return buildClientDetailsMetrics(ordersData, decodedCustomerKey, dateRange);
|
setIsLoading(true);
|
||||||
}, [dateRange, decodedCustomerKey, ordersData]);
|
const nextDetails = await fetchClientDetailsAnalytics(decodedClientToken, dateRange);
|
||||||
const displayName = requestedName || clientName || decodedCustomerKey.replace(/^name:/, '');
|
|
||||||
|
if (isMounted) {
|
||||||
|
setDetails(nextDetails);
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
void loadClientDetails();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
isMounted = false;
|
||||||
|
};
|
||||||
|
}, [dateRange, decodedClientToken]);
|
||||||
|
|
||||||
const formatCurrency = (value: number) => {
|
const formatCurrency = (value: number) => {
|
||||||
return new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(value);
|
return new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(value);
|
||||||
@@ -69,12 +79,7 @@ const ClientDetails = () => {
|
|||||||
setDateRange(range);
|
setDateRange(range);
|
||||||
};
|
};
|
||||||
|
|
||||||
const totalPages = Math.ceil(groupedOrders.length / ordersPerPage);
|
if (isLoading) {
|
||||||
const safeCurrentPage = Math.min(currentPage, totalPages || 1);
|
|
||||||
const startIndex = (safeCurrentPage - 1) * ordersPerPage;
|
|
||||||
const paginatedOrders = groupedOrders.slice(startIndex, startIndex + ordersPerPage);
|
|
||||||
|
|
||||||
if (!hasClient && isDataLoading) {
|
|
||||||
return (
|
return (
|
||||||
<div className="text-center py-12">
|
<div className="text-center py-12">
|
||||||
<p className="text-zinc-500 dark:text-dark-muted font-medium">Carregando cliente...</p>
|
<p className="text-zinc-500 dark:text-dark-muted font-medium">Carregando cliente...</p>
|
||||||
@@ -82,7 +87,7 @@ const ClientDetails = () => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!hasClient) {
|
if (!details?.hasClient) {
|
||||||
return (
|
return (
|
||||||
<div className="text-center py-12">
|
<div className="text-center py-12">
|
||||||
<p className="text-zinc-500 dark:text-dark-muted font-medium">Cliente não encontrado.</p>
|
<p className="text-zinc-500 dark:text-dark-muted font-medium">Cliente não encontrado.</p>
|
||||||
@@ -91,6 +96,23 @@ const ClientDetails = () => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
chartData,
|
||||||
|
groupedOrders,
|
||||||
|
allTimeOrderCount,
|
||||||
|
clientName,
|
||||||
|
clientPhone,
|
||||||
|
periodAverageTicket,
|
||||||
|
periodItems,
|
||||||
|
periodOrderCount,
|
||||||
|
periodSpent
|
||||||
|
} = details;
|
||||||
|
const displayName = clientName || 'Cliente';
|
||||||
|
const totalPages = Math.ceil(groupedOrders.length / ordersPerPage);
|
||||||
|
const safeCurrentPage = Math.min(currentPage, totalPages || 1);
|
||||||
|
const startIndex = (safeCurrentPage - 1) * ordersPerPage;
|
||||||
|
const paginatedOrders = groupedOrders.slice(startIndex, startIndex + ordersPerPage);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Header Area */}
|
{/* Header Area */}
|
||||||
|
|||||||
@@ -109,6 +109,7 @@ const Clients = () => {
|
|||||||
const rfmClient = rfmByCustomerKey.get(client.customerKey);
|
const rfmClient = rfmByCustomerKey.get(client.customerKey);
|
||||||
return {
|
return {
|
||||||
customerKey: client.customerKey,
|
customerKey: client.customerKey,
|
||||||
|
clientToken: client.clientToken,
|
||||||
name: client.name,
|
name: client.name,
|
||||||
phone: client.phone,
|
phone: client.phone,
|
||||||
totalSpent: client.totalSpent,
|
totalSpent: client.totalSpent,
|
||||||
@@ -315,7 +316,7 @@ const Clients = () => {
|
|||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-2.5 text-right">
|
<td className="px-6 py-2.5 text-right">
|
||||||
<Link
|
<Link
|
||||||
to={`/clients/${encodeURIComponent(client.customerKey)}?name=${encodeURIComponent(client.name)}`}
|
to={`/clients/${encodeURIComponent(client.clientToken)}`}
|
||||||
className="inline-flex items-center text-xs font-bold text-brand-primary hover:opacity-80 transition-opacity cursor-pointer"
|
className="inline-flex items-center text-xs font-bold text-brand-primary hover:opacity-80 transition-opacity cursor-pointer"
|
||||||
>
|
>
|
||||||
Ver detalhes
|
Ver detalhes
|
||||||
|
|||||||
@@ -533,7 +533,7 @@ const Rfm = () => {
|
|||||||
return (
|
return (
|
||||||
<tr key={client.customerKey} className="hover:bg-dark-input/50 transition-colors">
|
<tr key={client.customerKey} className="hover:bg-dark-input/50 transition-colors">
|
||||||
<td className="px-6 py-3">
|
<td className="px-6 py-3">
|
||||||
<Link to={`/clients/${encodeURIComponent(client.customerKey)}?name=${encodeURIComponent(client.name)}`} className="flex items-center gap-3 hover:text-brand-primary transition-colors">
|
<Link to={`/clients/${encodeURIComponent(client.clientToken)}`} 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">
|
<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" />
|
<Users className="h-4 w-4" />
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
30
src/types.ts
30
src/types.ts
@@ -65,6 +65,7 @@ export interface DashboardAnalytics {
|
|||||||
|
|
||||||
export interface ClientAnalyticsItem {
|
export interface ClientAnalyticsItem {
|
||||||
customerKey: string;
|
customerKey: string;
|
||||||
|
clientToken: string;
|
||||||
name: string;
|
name: string;
|
||||||
phone: string;
|
phone: string;
|
||||||
quantityPurchased: number;
|
quantityPurchased: number;
|
||||||
@@ -75,6 +76,7 @@ export interface ClientAnalyticsItem {
|
|||||||
|
|
||||||
export interface RfmClient {
|
export interface RfmClient {
|
||||||
customerKey: string;
|
customerKey: string;
|
||||||
|
clientToken: string;
|
||||||
name: string;
|
name: string;
|
||||||
phone: string;
|
phone: string;
|
||||||
monetary: number;
|
monetary: number;
|
||||||
@@ -112,6 +114,34 @@ export interface RfmAnalytics {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface GroupedClientOrder {
|
||||||
|
date: string;
|
||||||
|
orderId: string;
|
||||||
|
orderTotal: number;
|
||||||
|
items: OrderData[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ClientDetailsAnalytics {
|
||||||
|
range: {
|
||||||
|
start: string | null;
|
||||||
|
end: string | null;
|
||||||
|
};
|
||||||
|
clientToken: string;
|
||||||
|
chartData: Array<{
|
||||||
|
date: string;
|
||||||
|
value: number;
|
||||||
|
}>;
|
||||||
|
groupedOrders: GroupedClientOrder[];
|
||||||
|
allTimeOrderCount: number;
|
||||||
|
clientName: string;
|
||||||
|
clientPhone: string;
|
||||||
|
hasClient: boolean;
|
||||||
|
periodAverageTicket: number;
|
||||||
|
periodOrderCount: number;
|
||||||
|
periodSpent: number;
|
||||||
|
periodItems: 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