New filter panel layout at client page
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:
@@ -3,6 +3,7 @@ const { verifyToken } = require('../auth');
|
||||
const {
|
||||
getClientAnalytics,
|
||||
getClientDetailsAnalytics,
|
||||
getClientFilterOptions,
|
||||
getDashboardAnalytics,
|
||||
getProductAnalytics,
|
||||
getProductDetailsAnalytics,
|
||||
@@ -16,6 +17,13 @@ const getRange = (query) => ({
|
||||
end: query.end
|
||||
});
|
||||
|
||||
const getClientAnalyticsFilters = (query) => ({
|
||||
...getRange(query),
|
||||
marketplace: query.marketplace,
|
||||
canal_venda: query.canal_venda,
|
||||
seller: query.seller
|
||||
});
|
||||
|
||||
router.get('/analytics/dashboard', verifyToken, async (req, res) => {
|
||||
try {
|
||||
res.json(await getDashboardAnalytics(getRange(req.query)));
|
||||
@@ -51,13 +59,22 @@ router.get('/analytics/products/:productId/details', verifyToken, async (req, re
|
||||
|
||||
router.get('/analytics/clients', verifyToken, async (req, res) => {
|
||||
try {
|
||||
res.json(await getClientAnalytics(getRange(req.query)));
|
||||
res.json(await getClientAnalytics(getClientAnalyticsFilters(req.query)));
|
||||
} catch (error) {
|
||||
console.error('Error fetching client analytics:', error);
|
||||
res.status(500).json({ error: 'Internal Server Error' });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/analytics/clients/filters', verifyToken, async (req, res) => {
|
||||
try {
|
||||
res.json(await getClientFilterOptions(getRange(req.query)));
|
||||
} catch (error) {
|
||||
console.error('Error fetching client filter options:', error);
|
||||
res.status(500).json({ error: 'Internal Server Error' });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/analytics/clients/:clientToken/details', verifyToken, async (req, res) => {
|
||||
try {
|
||||
const details = await getClientDetailsAnalytics(req.params.clientToken, getRange(req.query));
|
||||
@@ -75,7 +92,7 @@ router.get('/analytics/clients/:clientToken/details', verifyToken, async (req, r
|
||||
|
||||
router.get('/analytics/rfm', verifyToken, async (req, res) => {
|
||||
try {
|
||||
res.json(await getRfmAnalytics(getRange(req.query)));
|
||||
res.json(await getRfmAnalytics(getClientAnalyticsFilters(req.query)));
|
||||
} catch (error) {
|
||||
console.error('Error fetching RFM analytics:', error);
|
||||
res.status(500).json({ error: 'Internal Server Error' });
|
||||
|
||||
@@ -125,25 +125,83 @@ const normalizeDateParam = (value) => {
|
||||
return `${yearValue}-${monthValue}-${dayValue}`;
|
||||
};
|
||||
|
||||
const buildDateFilter = ({ start, end } = {}) => {
|
||||
const normalizeTextFilter = (value) => {
|
||||
if (value === undefined || value === null) return '';
|
||||
return String(value).trim();
|
||||
};
|
||||
|
||||
const normalizeSellerFilter = (value) => {
|
||||
const normalizedValue = normalizeTextFilter(value);
|
||||
if (!normalizedValue) return null;
|
||||
|
||||
if (normalizedValue.startsWith('id:')) {
|
||||
const id = normalizeTextFilter(normalizedValue.slice(3));
|
||||
return id ? { type: 'id', value: id } : null;
|
||||
}
|
||||
|
||||
if (normalizedValue.startsWith('name:')) {
|
||||
const name = normalizeTextFilter(normalizedValue.slice(5));
|
||||
return name ? { type: 'name', value: name } : null;
|
||||
}
|
||||
|
||||
return { type: 'any', value: normalizedValue };
|
||||
};
|
||||
|
||||
const appendOrderMetadataFilters = (params, filters, range = {}) => {
|
||||
const marketplace = normalizeTextFilter(range.marketplace);
|
||||
const salesChannel = normalizeTextFilter(range.canal_venda || range.canalVenda);
|
||||
const seller = normalizeSellerFilter(range.seller || range.vendedor);
|
||||
|
||||
if (marketplace) {
|
||||
params.push(marketplace);
|
||||
filters.push(`NULLIF(TRIM(marketplace), '') = $${params.length}`);
|
||||
}
|
||||
|
||||
if (salesChannel) {
|
||||
params.push(salesChannel);
|
||||
filters.push(`NULLIF(TRIM(canal_venda), '') = $${params.length}`);
|
||||
}
|
||||
|
||||
if (seller?.type === 'id') {
|
||||
params.push(seller.value);
|
||||
filters.push(`NULLIF(TRIM(id_vendedor), '') = $${params.length}`);
|
||||
} else if (seller?.type === 'name') {
|
||||
params.push(seller.value);
|
||||
filters.push(`NULLIF(TRIM(nome_vendedor), '') = $${params.length}`);
|
||||
} else if (seller?.type === 'any') {
|
||||
params.push(seller.value);
|
||||
filters.push(`(NULLIF(TRIM(id_vendedor), '') = $${params.length} OR NULLIF(TRIM(nome_vendedor), '') = $${params.length})`);
|
||||
}
|
||||
};
|
||||
|
||||
const buildDateFilter = (range = {}) => {
|
||||
const { start, end } = range;
|
||||
const params = [];
|
||||
const filters = ['data_pedido_date IS NOT NULL'];
|
||||
const normalizedStart = normalizeDateParam(start);
|
||||
const normalizedEnd = normalizeDateParam(end);
|
||||
let startParamIndex = null;
|
||||
let endParamIndex = null;
|
||||
|
||||
if (normalizedStart) {
|
||||
params.push(normalizedStart);
|
||||
startParamIndex = params.length;
|
||||
filters.push(`data_pedido_date >= $${params.length}::date`);
|
||||
}
|
||||
|
||||
if (normalizedEnd) {
|
||||
params.push(normalizedEnd);
|
||||
endParamIndex = params.length;
|
||||
filters.push(`data_pedido_date <= $${params.length}::date`);
|
||||
}
|
||||
|
||||
appendOrderMetadataFilters(params, filters, range);
|
||||
|
||||
return {
|
||||
params,
|
||||
whereClause: `WHERE ${filters.join(' AND ')}`
|
||||
whereClause: `WHERE ${filters.join(' AND ')}`,
|
||||
startParamIndex,
|
||||
endParamIndex
|
||||
};
|
||||
};
|
||||
|
||||
@@ -576,6 +634,63 @@ const getClientAnalytics = async (range = {}) => {
|
||||
return clients;
|
||||
};
|
||||
|
||||
const getClientFilterOptions = async (range = {}) => {
|
||||
const dateRange = {
|
||||
start: range.start,
|
||||
end: range.end
|
||||
};
|
||||
const { params, whereClause } = buildDateFilter(dateRange);
|
||||
const [marketplaceResult, salesChannelResult, sellerResult] = await Promise.all([
|
||||
pool.query(`
|
||||
SELECT DISTINCT NULLIF(TRIM(marketplace), '') as value
|
||||
FROM orders
|
||||
${whereClause}
|
||||
AND NULLIF(TRIM(marketplace), '') IS NOT NULL
|
||||
ORDER BY value ASC;
|
||||
`, params),
|
||||
pool.query(`
|
||||
SELECT DISTINCT NULLIF(TRIM(canal_venda), '') as value
|
||||
FROM orders
|
||||
${whereClause}
|
||||
AND NULLIF(TRIM(canal_venda), '') IS NOT NULL
|
||||
ORDER BY value ASC;
|
||||
`, params),
|
||||
pool.query(`
|
||||
SELECT
|
||||
NULLIF(TRIM(id_vendedor), '') as id,
|
||||
NULLIF(TRIM(nome_vendedor), '') as name
|
||||
FROM orders
|
||||
${whereClause}
|
||||
AND (
|
||||
NULLIF(TRIM(id_vendedor), '') IS NOT NULL
|
||||
OR NULLIF(TRIM(nome_vendedor), '') IS NOT NULL
|
||||
)
|
||||
GROUP BY id, name
|
||||
ORDER BY COALESCE(name, id) ASC, id ASC;
|
||||
`, params)
|
||||
]);
|
||||
const sellerOptionsByValue = new Map();
|
||||
|
||||
sellerResult.rows.forEach(row => {
|
||||
const id = row.id || '';
|
||||
const name = row.name || '';
|
||||
const value = id ? `id:${id}` : `name:${name}`;
|
||||
if (!value || sellerOptionsByValue.has(value)) return;
|
||||
|
||||
sellerOptionsByValue.set(value, {
|
||||
value,
|
||||
id,
|
||||
name: name || id
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
marketplaces: marketplaceResult.rows.map(row => row.value).filter(Boolean),
|
||||
salesChannels: salesChannelResult.rows.map(row => row.value).filter(Boolean),
|
||||
sellers: [...sellerOptionsByValue.values()]
|
||||
};
|
||||
};
|
||||
|
||||
const getOrderGroupKey = (row) => (
|
||||
row.pedido_id ||
|
||||
`${row.data_pedido || getDateOnly(row.data_pedido_date) || ''}_${row.valor_pedido || 0}`
|
||||
@@ -720,14 +835,14 @@ const getClientDetailsAnalytics = async (clientToken, range = {}) => {
|
||||
};
|
||||
|
||||
const getRfmAnalytics = async (range = {}) => {
|
||||
const { params, whereClause } = buildDateFilter(range);
|
||||
const { params, whereClause, endParamIndex } = buildDateFilter(range);
|
||||
const normalizedStart = normalizeDateParam(range.start);
|
||||
const normalizedEnd = normalizeDateParam(range.end);
|
||||
const periodRecencyReferenceDate = normalizedEnd ? `$${params.length}::date` : 'CURRENT_DATE';
|
||||
const periodRecencyReferenceDate = normalizedEnd && endParamIndex ? `$${endParamIndex}::date` : 'CURRENT_DATE';
|
||||
const usePeriodAsHistory = !normalizedStart || normalizedStart <= '2000-01-01';
|
||||
|
||||
if (usePeriodAsHistory) {
|
||||
const clientRows = await getClientAnalytics({ end: normalizedEnd });
|
||||
const clientRows = await getClientAnalytics({ ...range, start: undefined, end: normalizedEnd });
|
||||
const recencyEnd = normalizedEnd || new Date().toISOString().slice(0, 10);
|
||||
const clients = buildRfmClients(clientRows.map(row => ({
|
||||
customerKey: row.customerKey,
|
||||
@@ -803,6 +918,11 @@ const getRfmAnalytics = async (range = {}) => {
|
||||
const recencyReferenceDate = normalizedEnd
|
||||
? `$${historyParams.push(normalizedEnd)}::date`
|
||||
: 'CURRENT_DATE';
|
||||
const historyFilters = [
|
||||
'data_pedido_date IS NOT NULL',
|
||||
`data_pedido_date <= ${recencyReferenceDate}`
|
||||
];
|
||||
appendOrderMetadataFilters(historyParams, historyFilters, range);
|
||||
const customerKeysParam = `$${historyParams.push(periodCustomerKeys)}::text[]`;
|
||||
|
||||
const historyResult = await client.query(`
|
||||
@@ -817,8 +937,7 @@ const getRfmAnalytics = async (range = {}) => {
|
||||
MAX(data_pedido_date) as last_purchase_date,
|
||||
GREATEST((${recencyReferenceDate} - MAX(data_pedido_date))::int, 0) as recency_days
|
||||
FROM orders
|
||||
WHERE data_pedido_date IS NOT NULL
|
||||
AND data_pedido_date <= ${recencyReferenceDate}
|
||||
WHERE ${historyFilters.join(' AND ')}
|
||||
GROUP BY customer_key
|
||||
),
|
||||
scored_history AS MATERIALIZED (
|
||||
@@ -923,6 +1042,7 @@ module.exports = {
|
||||
isClientToken,
|
||||
getFrequencyScore,
|
||||
getClientDetailsAnalytics,
|
||||
getClientFilterOptions,
|
||||
getPreviousDate,
|
||||
getRecencyScore,
|
||||
getRfmAnalytics,
|
||||
|
||||
@@ -10,6 +10,7 @@ const {
|
||||
isClientToken,
|
||||
getClientAnalytics,
|
||||
getClientDetailsAnalytics,
|
||||
getClientFilterOptions,
|
||||
getPreviousDate,
|
||||
getProductAnalytics,
|
||||
getProductDetailsAnalytics,
|
||||
@@ -91,6 +92,33 @@ test('buildDateFilter ignores invalid bounds', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('buildDateFilter composes client metadata predicates after date predicates', () => {
|
||||
const filter = buildDateFilter({
|
||||
start: '2026-05-01',
|
||||
end: '2026-05-28',
|
||||
marketplace: ' Mercado Livre ',
|
||||
canal_venda: ' Online ',
|
||||
seller: 'id:VEN-1'
|
||||
});
|
||||
|
||||
assert.deepEqual(filter.params, ['2026-05-01', '2026-05-28', 'Mercado Livre', 'Online', 'VEN-1']);
|
||||
assert.equal(filter.endParamIndex, 2);
|
||||
assert.equal(
|
||||
filter.whereClause,
|
||||
"WHERE data_pedido_date IS NOT NULL AND data_pedido_date >= $1::date AND data_pedido_date <= $2::date AND NULLIF(TRIM(marketplace), '') = $3 AND NULLIF(TRIM(canal_venda), '') = $4 AND NULLIF(TRIM(id_vendedor), '') = $5"
|
||||
);
|
||||
});
|
||||
|
||||
test('buildDateFilter can filter seller by display name when no seller id is selected', () => {
|
||||
const filter = buildDateFilter({ seller: 'name:Maria' });
|
||||
|
||||
assert.deepEqual(filter.params, ['Maria']);
|
||||
assert.equal(
|
||||
filter.whereClause,
|
||||
"WHERE data_pedido_date IS NOT NULL AND NULLIF(TRIM(nome_vendedor), '') = $1"
|
||||
);
|
||||
});
|
||||
|
||||
test('getPreviousDate returns the calendar day before an ISO date', () => {
|
||||
assert.equal(getPreviousDate('2026-06-15'), '2026-06-14');
|
||||
assert.equal(getPreviousDate('2026-03-01'), '2026-02-28');
|
||||
@@ -540,6 +568,50 @@ test('getClientAnalytics returns opaque client tokens', async () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('getClientFilterOptions returns distinct date-scoped order metadata options', async () => {
|
||||
const originalQuery = pool.query;
|
||||
const calls = [];
|
||||
|
||||
pool.query = async (sql, params = []) => {
|
||||
calls.push({ sql, params });
|
||||
|
||||
if (sql.includes('SELECT DISTINCT NULLIF(TRIM(marketplace)')) {
|
||||
return { rows: [{ value: 'Mercado Livre' }, { value: 'Shopee' }] };
|
||||
}
|
||||
|
||||
if (sql.includes('SELECT DISTINCT NULLIF(TRIM(canal_venda)')) {
|
||||
return { rows: [{ value: 'Online' }] };
|
||||
}
|
||||
|
||||
return {
|
||||
rows: [
|
||||
{ id: 'VEN-1', name: 'Maria' },
|
||||
{ id: '', name: 'Sem ID' },
|
||||
{ id: 'VEN-1', name: 'Maria' }
|
||||
]
|
||||
};
|
||||
};
|
||||
|
||||
try {
|
||||
const options = await getClientFilterOptions({ start: '2026-06-01', end: '2026-06-15' });
|
||||
|
||||
assert.equal(calls.length, 3);
|
||||
calls.forEach(call => {
|
||||
assert.deepEqual(call.params, ['2026-06-01', '2026-06-15']);
|
||||
assert.match(call.sql, /data_pedido_date >= \$1::date/);
|
||||
assert.match(call.sql, /data_pedido_date <= \$2::date/);
|
||||
});
|
||||
assert.deepEqual(options.marketplaces, ['Mercado Livre', 'Shopee']);
|
||||
assert.deepEqual(options.salesChannels, ['Online']);
|
||||
assert.deepEqual(options.sellers, [
|
||||
{ value: 'id:VEN-1', id: 'VEN-1', name: 'Maria' },
|
||||
{ value: 'name:Sem ID', id: '', name: 'Sem ID' }
|
||||
]);
|
||||
} finally {
|
||||
pool.query = originalQuery;
|
||||
}
|
||||
});
|
||||
|
||||
test('getClientDetailsAnalytics fetches only the tokenized client and period rows', async () => {
|
||||
const originalQuery = pool.query;
|
||||
const calls = [];
|
||||
|
||||
Reference in New Issue
Block a user