New filter panel layout at client page
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m20s

This commit is contained in:
Cauê Faleiros
2026-06-23 13:46:55 -03:00
parent 62e0239ee4
commit 3524405712
6 changed files with 642 additions and 107 deletions

View File

@@ -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' });

View File

@@ -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,

View File

@@ -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 = [];