Optimize products list analytics loading
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 54s
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 54s
This commit is contained in:
22
CONTEXT.md
22
CONTEXT.md
@@ -73,6 +73,10 @@ This project (often referred to as "Nexstar Graphs" or simply "Graphs") is a rea
|
|||||||
* **Campaign Observability:** The frontend has a `Campanhas` page backed by `/api/campaigns`, `/api/campaigns/preview`, `/api/campaigns/process`, and `/api/campaigns/retry`.
|
* **Campaign Observability:** The frontend has a `Campanhas` page backed by `/api/campaigns`, `/api/campaigns/preview`, `/api/campaigns/process`, and `/api/campaigns/retry`.
|
||||||
* **WhatsApp Marketing Integration:** The system extracts phone numbers from incoming n8n payloads (checking `Fone_Cliente`, `fone`, or `celular`). Numbers are exposed in the UI for direct "Click-to-Chat" links and exported to CSV files.
|
* **WhatsApp Marketing Integration:** The system extracts phone numbers from incoming n8n payloads (checking `Fone_Cliente`, `fone`, or `celular`). Numbers are exposed in the UI for direct "Click-to-Chat" links and exported to CSV files.
|
||||||
* **Filter Persistence:** User preferences for Date Ranges, Sort options, and Auto-Refresh intervals are persisted to `localStorage` to survive page reloads.
|
* **Filter Persistence:** User preferences for Date Ranges, Sort options, and Auto-Refresh intervals are persisted to `localStorage` to survive page reloads.
|
||||||
|
* **RFV Segmentation:** The Portuguese UI calls customer segmentation **RFV** (`Recência`, `Frequência`, `Valor`). Internal code/API names may still use `rfm` to avoid route/type churn. The visible app labels should use RFV.
|
||||||
|
* **RFV Date Semantics:** Date filters use local calendar-day boundaries and send stable `YYYY-MM-DD` query params. Backend analytics treats `start` and `end` as inclusive `data_pedido_date` bounds.
|
||||||
|
* **RFV Tag Aggregation Rule:** RFV is not recalculated from only the selected period. For a selected period, the backend first calculates each buyer's RFV tag using history **before the period starts**. It then aggregates only the purchases inside the selected period by that prior tag. Example: if a client was `Champions` before today and buys today, `Champions` gets +1 client and that period revenue. If an `Em Risco` client does not buy today, `Em Risco` does not get counted for today's filter.
|
||||||
|
* **RFV Display Rule:** The RFV table's `Última Compra` date is the purchase date inside the selected period. Date-only strings must be parsed as local dates in the frontend to avoid UTC shifting (`2026-06-15` must render as `15/06/2026`, not `14/06/2026`). The small recency text below it displays `Hoje`, `Ontem`, or `X dias` relative to the selected range end.
|
||||||
|
|
||||||
## 6. CI/CD & Deployment
|
## 6. CI/CD & Deployment
|
||||||
* **Gitea Actions:** A workflow located in `.gitea/workflows/deploy.yml` triggers on pushes to the `main` branch.
|
* **Gitea Actions:** A workflow located in `.gitea/workflows/deploy.yml` triggers on pushes to the `main` branch.
|
||||||
@@ -110,6 +114,11 @@ cd backend
|
|||||||
npm test
|
npm test
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Frontend Date/RFV Helper Test:**
|
||||||
|
```bash
|
||||||
|
node --experimental-strip-types --test src/dateRanges.test.ts
|
||||||
|
```
|
||||||
|
|
||||||
**Persistent Local Stack:**
|
**Persistent Local Stack:**
|
||||||
```bash
|
```bash
|
||||||
docker compose up -d --build
|
docker compose up -d --build
|
||||||
@@ -124,3 +133,16 @@ The local Docker services use `restart: unless-stopped`, so containers should co
|
|||||||
* **Database Migrations:** There is no ORM (like Prisma or Sequelize). Table schemas, indexes, and lightweight data repairs are managed via raw SQL statements inside `initDB()` in `backend/db.js` using `IF NOT EXISTS` and safe startup execution patterns.
|
* **Database Migrations:** There is no ORM (like Prisma or Sequelize). Table schemas, indexes, and lightweight data repairs are managed via raw SQL statements inside `initDB()` in `backend/db.js` using `IF NOT EXISTS` and safe startup execution patterns.
|
||||||
* **API Security:** All backend modifications exposing or altering data MUST use the `verifyToken` middleware for frontend requests or `authenticateAPIKey` for external n8n webhooks.
|
* **API Security:** All backend modifications exposing or altering data MUST use the `verifyToken` middleware for frontend requests or `authenticateAPIKey` for external n8n webhooks.
|
||||||
* **Build Discipline:** After frontend/backend behavior changes, run `npm run lint`, `npm run build`, and relevant backend tests. The user prefers builds after changes to catch issues before deployment.
|
* **Build Discipline:** After frontend/backend behavior changes, run `npm run lint`, `npm run build`, and relevant backend tests. The user prefers builds after changes to catch issues before deployment.
|
||||||
|
|
||||||
|
## 9. Recent RFV Work
|
||||||
|
Recent commits related to RFV/date behavior:
|
||||||
|
* `73033ca Aggregate RFM period buyers by prior tag` - backend RFV aggregation now groups period buyers by their prior tag.
|
||||||
|
* `ae73a54 Fix RFM period date display` - frontend date-only display and period recency fixed.
|
||||||
|
* `9a58b2a Improve RFM recency labels` - period recency labels show `Hoje`/`Ontem`/`X dias`.
|
||||||
|
* `f811d3c Rename visible RFM labels to RFV` - visible UI wording changed from RFM to RFV.
|
||||||
|
|
||||||
|
Files most relevant to RFV:
|
||||||
|
* `backend/services/analyticsService.js` - `getRfmAnalytics`, date filters, RFV score/tag logic.
|
||||||
|
* `backend/test/analyticsService.test.js` - unit coverage for date filters and RFV aggregation.
|
||||||
|
* `src/pages/Rfm.tsx` - RFV page UI, matrix/table/export labels, period recency display.
|
||||||
|
* `src/components/DateRangePicker.tsx` and `src/dateRanges.ts` - local calendar date ranges and ISO query param formatting.
|
||||||
|
|||||||
@@ -401,19 +401,42 @@ const getDashboardAnalytics = async (range = {}) => {
|
|||||||
const getProductAnalytics = async (range = {}) => {
|
const getProductAnalytics = async (range = {}) => {
|
||||||
const { params, whereClause } = buildDateFilter(range);
|
const { params, whereClause } = buildDateFilter(range);
|
||||||
const result = await pool.query(`
|
const result = await pool.query(`
|
||||||
|
WITH period_sales AS (
|
||||||
SELECT
|
SELECT
|
||||||
COALESCE(${PRODUCT_NAME_SQL}, 'Unknown') as name,
|
produto_id as id,
|
||||||
MAX(produto_id) as id,
|
MAX(COALESCE(NULLIF(produto_descricao, ''), 'Unknown')) as order_name,
|
||||||
COALESCE(SUM(quantidade), 0) as quantity_sold,
|
COALESCE(SUM(quantidade), 0) as quantity_sold,
|
||||||
COALESCE(SUM(quantidade * valor_unitario), 0) as revenue,
|
COALESCE(SUM(quantidade * valor_unitario), 0) as revenue,
|
||||||
COUNT(*)::int as order_line_count,
|
COUNT(*)::int as order_line_count,
|
||||||
MIN(data_pedido_date) as first_sale_date,
|
MIN(data_pedido_date) as first_sale_date,
|
||||||
MAX(data_pedido_date) as last_sale_date
|
MAX(data_pedido_date) as last_sale_date,
|
||||||
|
(ARRAY_AGG(valor_unitario ORDER BY data_pedido_date DESC NULLS LAST, data_pedido DESC NULLS LAST))[1] as last_price
|
||||||
FROM orders
|
FROM orders
|
||||||
${whereClause}
|
${whereClause}
|
||||||
GROUP BY name
|
GROUP BY produto_id
|
||||||
ORDER BY revenue DESC, quantity_sold DESC
|
),
|
||||||
LIMIT 500;
|
stock_rows AS (
|
||||||
|
SELECT
|
||||||
|
produto_id as id,
|
||||||
|
MAX(NULLIF(nome, '')) as stock_name,
|
||||||
|
COALESCE(MAX(saldo), 0) as stock
|
||||||
|
FROM stock
|
||||||
|
GROUP BY produto_id
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
COALESCE(period_sales.id, stock_rows.id) as id,
|
||||||
|
COALESCE(stock_rows.stock_name, period_sales.order_name, 'Unknown') as name,
|
||||||
|
COALESCE(period_sales.quantity_sold, 0) as quantity_sold,
|
||||||
|
COALESCE(period_sales.revenue, 0) as revenue,
|
||||||
|
COALESCE(period_sales.order_line_count, 0)::int as order_line_count,
|
||||||
|
period_sales.first_sale_date,
|
||||||
|
period_sales.last_sale_date,
|
||||||
|
COALESCE(period_sales.last_price, 0) as last_price,
|
||||||
|
COALESCE(stock_rows.stock, 0) as stock
|
||||||
|
FROM period_sales
|
||||||
|
FULL OUTER JOIN stock_rows ON stock_rows.id = period_sales.id
|
||||||
|
WHERE COALESCE(period_sales.id, stock_rows.id) IS NOT NULL
|
||||||
|
ORDER BY quantity_sold DESC, revenue DESC, name ASC;
|
||||||
`, params);
|
`, params);
|
||||||
|
|
||||||
return result.rows.map(row => ({
|
return result.rows.map(row => ({
|
||||||
@@ -422,6 +445,8 @@ const getProductAnalytics = async (range = {}) => {
|
|||||||
quantitySold: toNumber(row.quantity_sold),
|
quantitySold: toNumber(row.quantity_sold),
|
||||||
revenue: toNumber(row.revenue),
|
revenue: toNumber(row.revenue),
|
||||||
orderLineCount: toNumber(row.order_line_count),
|
orderLineCount: toNumber(row.order_line_count),
|
||||||
|
lastPrice: toNumber(row.last_price),
|
||||||
|
stock: toNumber(row.stock),
|
||||||
firstSaleDate: row.first_sale_date,
|
firstSaleDate: row.first_sale_date,
|
||||||
lastSaleDate: row.last_sale_date
|
lastSaleDate: row.last_sale_date
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ const {
|
|||||||
getClientAnalytics,
|
getClientAnalytics,
|
||||||
getClientDetailsAnalytics,
|
getClientDetailsAnalytics,
|
||||||
getPreviousDate,
|
getPreviousDate,
|
||||||
|
getProductAnalytics,
|
||||||
getRecencyScore,
|
getRecencyScore,
|
||||||
getRfmAnalytics,
|
getRfmAnalytics,
|
||||||
getRfmSegment,
|
getRfmSegment,
|
||||||
@@ -330,6 +331,67 @@ 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('getProductAnalytics returns exact product rows with stock and latest price', async () => {
|
||||||
|
const originalQuery = pool.query;
|
||||||
|
const calls = [];
|
||||||
|
|
||||||
|
pool.query = async (sql, params = []) => {
|
||||||
|
calls.push({ sql, params });
|
||||||
|
|
||||||
|
return {
|
||||||
|
rows: [
|
||||||
|
{
|
||||||
|
id: '919483307',
|
||||||
|
name: 'BASE LISA CAMISETA COR PRETO TAMANHO - G',
|
||||||
|
quantity_sold: 9513,
|
||||||
|
revenue: 113216.83,
|
||||||
|
order_line_count: 100,
|
||||||
|
last_price: 11.9,
|
||||||
|
stock: 11731,
|
||||||
|
first_sale_date: '2026-06-01',
|
||||||
|
last_sale_date: '2026-06-22'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'stock-only',
|
||||||
|
name: 'Produto sem venda no período',
|
||||||
|
quantity_sold: 0,
|
||||||
|
revenue: 0,
|
||||||
|
order_line_count: 0,
|
||||||
|
last_price: 0,
|
||||||
|
stock: 12,
|
||||||
|
first_sale_date: null,
|
||||||
|
last_sale_date: null
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const products = await getProductAnalytics({ start: '2026-06-01', end: '2026-06-22' });
|
||||||
|
|
||||||
|
assert.equal(calls.length, 1);
|
||||||
|
assert.match(calls[0].sql, /WITH period_sales AS/);
|
||||||
|
assert.match(calls[0].sql, /FULL OUTER JOIN stock_rows/);
|
||||||
|
assert.doesNotMatch(calls[0].sql, /GROUP BY name/);
|
||||||
|
assert.deepEqual(calls[0].params, ['2026-06-01', '2026-06-22']);
|
||||||
|
assert.deepEqual(products[0], {
|
||||||
|
id: '919483307',
|
||||||
|
name: 'BASE LISA CAMISETA COR PRETO TAMANHO - G',
|
||||||
|
quantitySold: 9513,
|
||||||
|
revenue: 113216.83,
|
||||||
|
orderLineCount: 100,
|
||||||
|
lastPrice: 11.9,
|
||||||
|
stock: 11731,
|
||||||
|
firstSaleDate: '2026-06-01',
|
||||||
|
lastSaleDate: '2026-06-22'
|
||||||
|
});
|
||||||
|
assert.equal(products[1].id, 'stock-only');
|
||||||
|
assert.equal(products[1].stock, 12);
|
||||||
|
} finally {
|
||||||
|
pool.query = originalQuery;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('getClientAnalytics returns opaque client tokens', async () => {
|
test('getClientAnalytics returns opaque client tokens', async () => {
|
||||||
const originalQuery = pool.query;
|
const originalQuery = pool.query;
|
||||||
const calls = [];
|
const calls = [];
|
||||||
|
|||||||
@@ -7,7 +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/');
|
||||||
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(() => {
|
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(() => {
|
||||||
return localStorage.getItem('graph_sidebar_collapsed') === 'true';
|
return localStorage.getItem('graph_sidebar_collapsed') === 'true';
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { AuthUser, CampaignPreview, CampaignProcessSummary, CampaignQueueSummary, ClientAnalyticsItem, ClientDetailsAnalytics, CreateUserResult, DashboardAnalytics, DateRange, ManagedUser, OrderData, RfmAnalytics, StockData } from './types';
|
import type { AuthUser, CampaignPreview, CampaignProcessSummary, CampaignQueueSummary, ClientAnalyticsItem, ClientDetailsAnalytics, CreateUserResult, DashboardAnalytics, DateRange, ManagedUser, OrderData, ProductAnalyticsItem, 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';
|
||||||
@@ -124,6 +124,21 @@ export const fetchDashboardAnalytics = async (dateRange: DateRange): Promise<Das
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const fetchProductAnalytics = async (dateRange: DateRange): Promise<ProductAnalyticsItem[]> => {
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
start: formatDateParam(dateRange.start),
|
||||||
|
end: formatDateParam(dateRange.end)
|
||||||
|
});
|
||||||
|
const response = await authFetch(`/analytics/products?${params.toString()}`);
|
||||||
|
if (!response.ok) return [];
|
||||||
|
return await response.json();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Fetch product analytics failed', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export const fetchRfmAnalytics = async (dateRange: DateRange): Promise<RfmAnalytics | null> => {
|
export const fetchRfmAnalytics = async (dateRange: DateRange): Promise<RfmAnalytics | null> => {
|
||||||
try {
|
try {
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
|
|||||||
@@ -1,30 +1,63 @@
|
|||||||
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, Package, TrendingUp, ChevronLeft, ChevronRight, Download } from 'lucide-react';
|
import { Search, Package, TrendingUp, ChevronLeft, ChevronRight, Download, Loader2 } from 'lucide-react';
|
||||||
import DateRangePicker from '../components/DateRangePicker';
|
import DateRangePicker from '../components/DateRangePicker';
|
||||||
import type { OrderData, DateRange, StockData } from '../types';
|
import type { DateRange, ProductAnalyticsItem } from '../types';
|
||||||
import { exportToCSV } from '../dataService';
|
import { exportToCSV, fetchProductAnalytics } from '../dataService';
|
||||||
import { buildProductsSummary } from '../analytics/products';
|
import type { ProductSummary } from '../analytics/products';
|
||||||
|
|
||||||
const Products = () => {
|
const Products = () => {
|
||||||
const { dateRange, setDateRange, ordersData, stockData } = useOutletContext<{
|
const { dateRange, setDateRange } = useOutletContext<{
|
||||||
dateRange: DateRange,
|
dateRange: DateRange,
|
||||||
setDateRange: (range: DateRange) => void,
|
setDateRange: (range: DateRange) => void
|
||||||
ordersData: OrderData[],
|
|
||||||
stockData: StockData[],
|
|
||||||
refreshInterval: number,
|
|
||||||
setRefreshInterval: (interval: number) => void,
|
|
||||||
loadData: (showLoading?: boolean) => void
|
|
||||||
}>();
|
}>();
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
const [productAnalytics, setProductAnalytics] = useState<ProductAnalyticsItem[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
// 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 loadProducts = async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
const products = await fetchProductAnalytics(dateRange);
|
||||||
|
|
||||||
|
if (isMounted) {
|
||||||
|
setProductAnalytics(products);
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
void loadProducts();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
isMounted = false;
|
||||||
|
};
|
||||||
|
}, [dateRange]);
|
||||||
|
|
||||||
const productsData = useMemo(() => {
|
const productsData = useMemo(() => {
|
||||||
return buildProductsSummary(ordersData, stockData, dateRange, searchTerm);
|
const normalizedSearch = searchTerm.trim().toLowerCase();
|
||||||
}, [dateRange, searchTerm, ordersData, stockData]);
|
const products: ProductSummary[] = productAnalytics.map(product => ({
|
||||||
|
id: product.id,
|
||||||
|
name: product.name,
|
||||||
|
totalSold: product.quantitySold,
|
||||||
|
revenue: product.revenue,
|
||||||
|
lastPrice: product.lastPrice,
|
||||||
|
stock: product.stock
|
||||||
|
}));
|
||||||
|
const filteredProducts = normalizedSearch
|
||||||
|
? products.filter(product =>
|
||||||
|
product.name.toLowerCase().includes(normalizedSearch) ||
|
||||||
|
product.id.toLowerCase().includes(normalizedSearch)
|
||||||
|
)
|
||||||
|
: products;
|
||||||
|
|
||||||
|
return filteredProducts.sort((a, b) => b.totalSold - a.totalSold);
|
||||||
|
}, [productAnalytics, searchTerm]);
|
||||||
|
|
||||||
// Pagination logic
|
// Pagination logic
|
||||||
const totalPages = Math.ceil(productsData.length / itemsPerPage);
|
const totalPages = Math.ceil(productsData.length / itemsPerPage);
|
||||||
@@ -86,6 +119,13 @@ const Products = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{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 produtos
|
||||||
|
</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">
|
||||||
|
|||||||
12
src/types.ts
12
src/types.ts
@@ -69,6 +69,18 @@ export interface DashboardAnalytics {
|
|||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ProductAnalyticsItem {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
quantitySold: number;
|
||||||
|
revenue: number;
|
||||||
|
orderLineCount: number;
|
||||||
|
lastPrice: number;
|
||||||
|
stock: number;
|
||||||
|
firstSaleDate: string | null;
|
||||||
|
lastSaleDate: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ClientAnalyticsItem {
|
export interface ClientAnalyticsItem {
|
||||||
customerKey: string;
|
customerKey: string;
|
||||||
clientToken: string;
|
clientToken: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user