Compare commits

...

9 Commits

Author SHA1 Message Date
Cauê Faleiros
0d6ef40c8e fix: preserve etiqueta product variants
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 42s
2026-06-01 09:54:30 -03:00
Cauê Faleiros
fce7bbf975 fix: title case campaign product names 2026-06-01 09:47:35 -03:00
Cauê Faleiros
a1aa071e1d fix: normalize campaign product size suffixes
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m44s
2026-06-01 09:27:54 -03:00
Cauê Faleiros
b886b357d7 perf: load dashboard metrics from analytics API
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 50s
2026-05-28 11:59:56 -03:00
Cauê Faleiros
f4cf4366ee perf: avoid blocking page render on data refresh
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 41s
2026-05-28 11:46:11 -03:00
Cauê Faleiros
6dbc5ee190 perf: code split frontend routes
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 54s
2026-05-28 11:31:50 -03:00
Cauê Faleiros
cfdeb03786 feat: add backend analytics endpoints 2026-05-28 11:28:06 -03:00
Cauê Faleiros
fd89204973 feat: normalize order dates in database 2026-05-28 11:23:47 -03:00
Cauê Faleiros
3da299a8af test: add campaign queue coverage 2026-05-28 11:18:45 -03:00
23 changed files with 861 additions and 148 deletions

View File

@@ -7,22 +7,46 @@ const pool = new Pool({
const initDB = async () => {
try {
await pool.query(`SET TIME ZONE 'America/Sao_Paulo';`);
await pool.query(`
CREATE TABLE IF NOT EXISTS orders (
id SERIAL PRIMARY KEY,
cliente_nome VARCHAR(255),
data_pedido VARCHAR(50),
data_pedido_date DATE,
valor_pedido NUMERIC(10, 2),
produto_id VARCHAR(100),
produto_descricao TEXT,
quantidade INTEGER,
valor_unitario NUMERIC(10, 5),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
`);
await pool.query(`ALTER TABLE orders ADD COLUMN IF NOT EXISTS pedido_id VARCHAR(100);`).catch(() => {});
await pool.query(`ALTER TABLE orders ADD COLUMN IF NOT EXISTS cliente_fone VARCHAR(50);`).catch(() => {});
await pool.query(`ALTER TABLE orders ADD COLUMN IF NOT EXISTS data_pedido_date DATE;`).catch(() => {});
await pool.query(`
ALTER TABLE orders
ALTER COLUMN created_at TYPE TIMESTAMPTZ USING created_at AT TIME ZONE 'America/Sao_Paulo',
ALTER COLUMN created_at SET DEFAULT CURRENT_TIMESTAMP;
`).catch(() => {});
await pool.query(`
UPDATE orders
SET data_pedido_date = CASE
WHEN data_pedido ~ '^\\d{4}[-/]\\d{1,2}[-/]\\d{1,2}' THEN to_date(replace(left(data_pedido, 10), '/', '-'), 'YYYY-MM-DD')
WHEN data_pedido ~ '^\\d{1,2}[-/]\\d{1,2}[-/]\\d{4}' THEN to_date(replace(left(data_pedido, 10), '/', '-'), 'DD-MM-YYYY')
ELSE NULL
END
WHERE data_pedido_date IS NULL
AND data_pedido IS NOT NULL
AND data_pedido != '';
`).catch(err => {
console.error('Notice: Could not backfill normalized order dates:', err.message);
});
await pool.query(`CREATE UNIQUE INDEX IF NOT EXISTS unique_order_product ON orders (pedido_id, produto_id);`).catch(err => {
console.error('Notice: Could not create unique index (might already exist or there are duplicates):', err.message);
@@ -34,10 +58,16 @@ const initDB = async () => {
nome TEXT,
saldo INTEGER DEFAULT 0,
delta_estoque INTEGER DEFAULT 0,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
`);
await pool.query(`
ALTER TABLE stock
ALTER COLUMN updated_at TYPE TIMESTAMPTZ USING updated_at AT TIME ZONE 'America/Sao_Paulo',
ALTER COLUMN updated_at SET DEFAULT CURRENT_TIMESTAMP;
`).catch(() => {});
await pool.query(`
CREATE TABLE IF NOT EXISTS stock_campaign_queue (
id SERIAL PRIMARY KEY,
@@ -49,15 +79,49 @@ const initDB = async () => {
status VARCHAR(20) DEFAULT 'pending',
attempts INTEGER DEFAULT 0,
last_error TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
sent_at TIMESTAMP
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
sent_at TIMESTAMPTZ
);
`);
await pool.query(`
ALTER TABLE stock_campaign_queue
ALTER COLUMN created_at TYPE TIMESTAMPTZ USING created_at AT TIME ZONE 'America/Sao_Paulo',
ALTER COLUMN created_at SET DEFAULT CURRENT_TIMESTAMP,
ALTER COLUMN updated_at TYPE TIMESTAMPTZ USING updated_at AT TIME ZONE 'America/Sao_Paulo',
ALTER COLUMN updated_at SET DEFAULT CURRENT_TIMESTAMP,
ALTER COLUMN sent_at TYPE TIMESTAMPTZ USING sent_at AT TIME ZONE 'America/Sao_Paulo';
`).catch(() => {});
await pool.query(`
UPDATE stock_campaign_queue
SET base_product_name = TRIM(regexp_replace(
base_product_name,
'\\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}))*)$',
'',
'i'
))
WHERE status IN ('pending', 'failed', 'processing')
AND base_product_name ~* '\\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}))*)$';
`).catch(err => {
console.error('Notice: Could not normalize queued campaign product names:', err.message);
});
await pool.query(`
UPDATE stock_campaign_queue
SET base_product_name = nome
WHERE status IN ('pending', 'failed', 'processing')
AND nome ILIKE 'ETIQUETA%'
AND base_product_name != nome;
`).catch(err => {
console.error('Notice: Could not restore queued etiqueta product names:', err.message);
});
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_produto_id ON orders (produto_id);`);
await pool.query(`CREATE INDEX IF NOT EXISTS idx_orders_data_pedido_date ON orders (data_pedido_date);`);
console.log('Database initialized successfully.');
} catch (err) {

View File

@@ -11,14 +11,40 @@ const formatOrderRow = (row) => ({
Fone_Cliente: row.cliente_fone
});
const normalizeOrderDate = (dateValue) => {
if (!dateValue) return null;
const value = String(dateValue).trim();
const match = value.match(/^(\d{1,4})[-/](\d{1,2})[-/](\d{1,4})/);
if (!match) return null;
const [, first, second, third] = match;
const year = first.length === 4 ? Number(first) : Number(third);
const month = Number(second);
const day = first.length === 4 ? Number(third) : Number(first);
const date = new Date(Date.UTC(year, month - 1, day));
if (
date.getUTCFullYear() !== year ||
date.getUTCMonth() !== month - 1 ||
date.getUTCDate() !== day
) {
return null;
}
return date.toISOString().slice(0, 10);
};
const normalizeOrderPayload = (item) => {
const fallbackId = `${item.Nome_Cliente}_${item.Data_Pedido}_${item.Valor_Pedido}`;
const orderId = item.id || item.ID_Pedido || (item.json && item.json.body && item.json.body.id) || fallbackId;
const fone = item.Fone_Cliente || item.fone || item.celular || '';
const orderDate = item.Data_Pedido || '';
return [
item.Nome_Cliente || 'Unknown',
item.Data_Pedido || '',
orderDate,
normalizeOrderDate(orderDate),
parseFloat(item.Valor_Pedido) || 0,
item.ID_Produto || '',
item.Descricao_Produto || '',
@@ -31,5 +57,6 @@ const normalizeOrderPayload = (item) => {
module.exports = {
formatOrderRow,
normalizeOrderDate,
normalizeOrderPayload
};

View File

@@ -1,4 +1,16 @@
const getBaseProductName = (name) => String(name || 'Unknown').split(' TAMANHO')[0].trim();
const SIZE_SUFFIX_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}))*)$/i;
const getBaseProductName = (name) => {
const productName = String(name || 'Unknown').trim();
if (productName.toLocaleUpperCase('pt-BR').startsWith('ETIQUETA')) {
return productName;
}
return productName
.split(' TAMANHO')[0]
.replace(SIZE_SUFFIX_PATTERN, '')
.trim();
};
const normalizeStockPayload = (item) => {
const produtoId = item.idProduto || item.ID_Produto || '';

View File

@@ -5,7 +5,7 @@
"main": "index.js",
"scripts": {
"start": "node index.js",
"test": "echo \"Error: no test specified\" && exit 1"
"test": "node --test"
},
"keywords": [],
"author": "",

View File

@@ -0,0 +1,43 @@
const express = require('express');
const { verifyToken } = require('../auth');
const {
getClientAnalytics,
getDashboardAnalytics,
getProductAnalytics
} = require('../services/analyticsService');
const router = express.Router();
const getRange = (query) => ({
start: query.start,
end: query.end
});
router.get('/analytics/dashboard', verifyToken, async (req, res) => {
try {
res.json(await getDashboardAnalytics(getRange(req.query)));
} catch (error) {
console.error('Error fetching dashboard analytics:', error);
res.status(500).json({ error: 'Internal Server Error' });
}
});
router.get('/analytics/products', verifyToken, async (req, res) => {
try {
res.json(await getProductAnalytics(getRange(req.query)));
} catch (error) {
console.error('Error fetching product analytics:', error);
res.status(500).json({ error: 'Internal Server Error' });
}
});
router.get('/analytics/clients', verifyToken, async (req, res) => {
try {
res.json(await getClientAnalytics(getRange(req.query)));
} catch (error) {
console.error('Error fetching client analytics:', error);
res.status(500).json({ error: 'Internal Server Error' });
}
});
module.exports = router;

View File

@@ -6,6 +6,7 @@ const dataRoutes = require('./routes/dataRoutes');
const stockRoutes = require('./routes/stockRoutes');
const campaignRoutes = require('./routes/campaignRoutes');
const internalRoutes = require('./routes/internalRoutes');
const analyticsRoutes = require('./routes/analyticsRoutes');
const createApp = () => {
const app = express();
@@ -17,6 +18,7 @@ const createApp = () => {
app.use('/api', dataRoutes);
app.use('/api', stockRoutes);
app.use('/api', campaignRoutes);
app.use('/api', analyticsRoutes);
app.use('/api/internal', internalRoutes);
return app;

View File

@@ -0,0 +1,183 @@
const { pool } = require('../db');
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 = `
CASE
WHEN COALESCE(produto_descricao, 'Unknown') ILIKE 'ETIQUETA%' THEN COALESCE(produto_descricao, 'Unknown')
ELSE NULLIF(TRIM(regexp_replace(split_part(COALESCE(produto_descricao, 'Unknown'), ' TAMANHO', 1), '${SIZE_SUFFIX_SQL_PATTERN}', '', 'i')), '')
END
`;
const normalizeDateParam = (value) => {
if (!value) return null;
const match = String(value).trim().match(/^(\d{4})-(\d{2})-(\d{2})$/);
if (!match) return null;
const [, yearValue, monthValue, dayValue] = match;
const year = Number(yearValue);
const month = Number(monthValue);
const day = Number(dayValue);
const date = new Date(Date.UTC(year, month - 1, day));
if (
date.getUTCFullYear() !== year ||
date.getUTCMonth() !== month - 1 ||
date.getUTCDate() !== day
) {
return null;
}
return `${yearValue}-${monthValue}-${dayValue}`;
};
const buildDateFilter = ({ start, end } = {}) => {
const params = [];
const filters = ['data_pedido_date IS NOT NULL'];
const normalizedStart = normalizeDateParam(start);
const normalizedEnd = normalizeDateParam(end);
if (normalizedStart) {
params.push(normalizedStart);
filters.push(`data_pedido_date >= $${params.length}::date`);
}
if (normalizedEnd) {
params.push(normalizedEnd);
filters.push(`data_pedido_date <= $${params.length}::date`);
}
return {
params,
whereClause: `WHERE ${filters.join(' AND ')}`
};
};
const toNumber = (value) => Number(value || 0);
const getDashboardAnalytics = async (range = {}) => {
const { params, whereClause } = buildDateFilter(range);
const [totalsResult, salesResult, revenueResult] = await Promise.all([
pool.query(`
SELECT
COALESCE(SUM(quantidade * valor_unitario), 0) as total_revenue,
COALESCE(SUM(quantidade), 0) as total_items,
COUNT(*)::int as order_line_count
FROM orders
${whereClause};
`, params),
pool.query(`
SELECT
COALESCE(${PRODUCT_NAME_SQL}, 'Unknown') as name,
MAX(produto_id) as id,
COALESCE(SUM(quantidade), 0) as value
FROM orders
${whereClause}
GROUP BY name
ORDER BY value DESC
LIMIT 10;
`, params),
pool.query(`
SELECT
COALESCE(${PRODUCT_NAME_SQL}, 'Unknown') as name,
MAX(produto_id) as id,
COALESCE(SUM(quantidade * valor_unitario), 0) as value
FROM orders
${whereClause}
GROUP BY name
ORDER BY value DESC
LIMIT 10;
`, params)
]);
const totals = totalsResult.rows[0] || {};
const orderLineCount = toNumber(totals.order_line_count);
const totalRevenue = toNumber(totals.total_revenue);
return {
range: {
start: normalizeDateParam(range.start),
end: normalizeDateParam(range.end)
},
totalRevenue,
totalOrders: toNumber(totals.total_items),
orderLineCount,
averageOrderValue: orderLineCount ? totalRevenue / orderLineCount : 0,
salesByProduct: salesResult.rows.map(row => ({
name: row.name,
id: row.id,
value: toNumber(row.value)
})),
revenueByProduct: revenueResult.rows.map(row => ({
name: row.name,
id: row.id,
value: toNumber(row.value)
}))
};
};
const getProductAnalytics = async (range = {}) => {
const { params, whereClause } = buildDateFilter(range);
const result = await pool.query(`
SELECT
COALESCE(${PRODUCT_NAME_SQL}, 'Unknown') as name,
MAX(produto_id) as id,
COALESCE(SUM(quantidade), 0) as quantity_sold,
COALESCE(SUM(quantidade * valor_unitario), 0) as revenue,
COUNT(*)::int as order_line_count,
MIN(data_pedido_date) as first_sale_date,
MAX(data_pedido_date) as last_sale_date
FROM orders
${whereClause}
GROUP BY name
ORDER BY revenue DESC, quantity_sold DESC
LIMIT 500;
`, params);
return result.rows.map(row => ({
name: row.name,
id: row.id,
quantitySold: toNumber(row.quantity_sold),
revenue: toNumber(row.revenue),
orderLineCount: toNumber(row.order_line_count),
firstSaleDate: row.first_sale_date,
lastSaleDate: row.last_sale_date
}));
};
const getClientAnalytics = 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), 0) as quantity_purchased,
COALESCE(SUM(quantidade * valor_unitario), 0) as total_spent,
COUNT(*)::int as order_line_count,
MAX(data_pedido_date) as last_purchase_date
FROM orders
${whereClause}
AND cliente_fone IS NOT NULL
AND cliente_fone != ''
GROUP BY cliente_fone
ORDER BY total_spent DESC
LIMIT 500;
`, params);
return result.rows.map(row => ({
name: row.name,
phone: row.phone,
quantityPurchased: toNumber(row.quantity_purchased),
totalSpent: toNumber(row.total_spent),
orderLineCount: toNumber(row.order_line_count),
lastPurchaseDate: row.last_purchase_date
}));
};
module.exports = {
buildDateFilter,
getClientAnalytics,
getDashboardAnalytics,
getProductAnalytics,
normalizeDateParam
};

View File

@@ -0,0 +1,103 @@
const formatProductNameForDisplay = (name) => {
return String(name || '')
.toLocaleLowerCase('pt-BR')
.replace(/(^|[\s/-])(\p{L})/gu, (_, separator, letter) => {
return `${separator}${letter.toLocaleUpperCase('pt-BR')}`;
});
};
const formatProductList = (productNames) => {
const displayNames = productNames.map(formatProductNameForDisplay);
if (displayNames.length <= 2) {
return displayNames.join(' e ');
}
return `${displayNames.slice(0, -1).join(', ')} e ${displayNames[displayNames.length - 1]}`;
};
const groupCampaignRowsByBaseProduct = (rows) => {
return rows.reduce((acc, row) => {
if (!acc[row.base_product_name]) acc[row.base_product_name] = [];
acc[row.base_product_name].push(row);
return acc;
}, {});
};
const mapCampaignProducts = (groups) => {
return Object.entries(groups)
.sort(([, aItems], [, bItems]) => {
return new Date(aItems[0].created_at).getTime() - new Date(bItems[0].created_at).getTime();
})
.map(([baseProductName, items]) => {
const sortedItems = [...items].sort((a, b) => String(a.nome).localeCompare(String(b.nome), 'pt-BR'));
return {
baseProduct: formatProductNameForDisplay(baseProductName),
total_delta: sortedItems.reduce((sum, item) => sum + Number(item.delta_estoque || 0), 0),
sizes: sortedItems.map(item => ({
id: item.produto_id,
nome: item.nome,
delta: item.delta_estoque,
saldo: item.saldo
})),
itemIds: sortedItems.map(item => item.id)
};
});
};
const buildWhatsappCampaignPayload = (products, customers) => {
const productNames = products.map(product => product.baseProduct);
const productsText = formatProductList(productNames);
const allSizes = products.flatMap(product => product.sizes);
const totalDelta = products.reduce((sum, product) => sum + product.total_delta, 0);
return {
baseProduct: productsText,
productsText,
total_delta: totalDelta,
sizes: allSizes,
products: products.map(({ itemIds, ...product }) => product),
customers
};
};
const groupCampaignRows = (rows) => {
return Object.values(rows.reduce((acc, row) => {
const key = `${row.base_product_name}:${row.status}`;
if (!acc[key]) {
acc[key] = {
key,
baseProductName: row.base_product_name,
status: row.status,
totalDelta: 0,
rowCount: 0,
attempts: 0,
lastError: null,
createdAt: row.created_at,
updatedAt: row.updated_at,
sentAt: row.sent_at,
items: []
};
}
acc[key].totalDelta += Number(row.delta_estoque || 0);
acc[key].rowCount += 1;
acc[key].attempts = Math.max(acc[key].attempts, Number(row.attempts || 0));
acc[key].lastError = row.last_error || acc[key].lastError;
acc[key].createdAt = new Date(row.created_at) < new Date(acc[key].createdAt) ? row.created_at : acc[key].createdAt;
acc[key].updatedAt = new Date(row.updated_at) > new Date(acc[key].updatedAt) ? row.updated_at : acc[key].updatedAt;
acc[key].sentAt = row.sent_at || acc[key].sentAt;
acc[key].items.push(row);
return acc;
}, {})).sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
};
module.exports = {
buildWhatsappCampaignPayload,
formatProductNameForDisplay,
formatProductList,
groupCampaignRows,
groupCampaignRowsByBaseProduct,
mapCampaignProducts
};

View File

@@ -1,5 +1,12 @@
const { pool } = require('../db');
const { N8N_WHATSAPP_TRIGGER_URL } = require('../config');
const {
buildWhatsappCampaignPayload,
formatProductList,
groupCampaignRows,
groupCampaignRowsByBaseProduct,
mapCampaignProducts
} = require('./campaignFormatter');
const TOP_BUYERS_LIMIT = 100;
const MAX_CAMPAIGN_ATTEMPTS = 3;
@@ -109,37 +116,6 @@ const getCampaignQueueRows = async () => {
return result.rows;
};
const groupCampaignRows = (rows) => {
return Object.values(rows.reduce((acc, row) => {
const key = `${row.base_product_name}:${row.status}`;
if (!acc[key]) {
acc[key] = {
key,
baseProductName: row.base_product_name,
status: row.status,
totalDelta: 0,
rowCount: 0,
attempts: 0,
lastError: null,
createdAt: row.created_at,
updatedAt: row.updated_at,
sentAt: row.sent_at,
items: []
};
}
acc[key].totalDelta += Number(row.delta_estoque || 0);
acc[key].rowCount += 1;
acc[key].attempts = Math.max(acc[key].attempts, Number(row.attempts || 0));
acc[key].lastError = row.last_error || acc[key].lastError;
acc[key].createdAt = new Date(row.created_at) < new Date(acc[key].createdAt) ? row.created_at : acc[key].createdAt;
acc[key].updatedAt = new Date(row.updated_at) > new Date(acc[key].updatedAt) ? row.updated_at : acc[key].updatedAt;
acc[key].sentAt = row.sent_at || acc[key].sentAt;
acc[key].items.push(row);
return acc;
}, {})).sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
};
const getCampaignQueueSummary = async () => {
const rows = await getCampaignQueueRows();
return {
@@ -158,11 +134,7 @@ const getCampaignPreview = async () => {
AND attempts < $1
ORDER BY created_at ASC, id ASC;
`, [MAX_CAMPAIGN_ATTEMPTS]);
const groups = result.rows.reduce((acc, row) => {
if (!acc[row.base_product_name]) acc[row.base_product_name] = [];
acc[row.base_product_name].push(row);
return acc;
}, {});
const groups = groupCampaignRowsByBaseProduct(result.rows);
const readyGroups = {};
const belowThresholdGroups = {};
@@ -233,53 +205,11 @@ const updateCampaignItemsStatus = async (ids, status, errorMessage = null) => {
`, [status, errorMessage, ids]);
};
const formatProductList = (productNames) => {
if (productNames.length <= 2) {
return productNames.join(' e ');
}
return `${productNames.slice(0, -1).join(', ')} e ${productNames[productNames.length - 1]}`;
};
const mapCampaignProducts = (groups) => {
return Object.entries(groups)
.sort(([, aItems], [, bItems]) => {
return new Date(aItems[0].created_at).getTime() - new Date(bItems[0].created_at).getTime();
})
.map(([baseProductName, items]) => {
const sortedItems = [...items].sort((a, b) => String(a.nome).localeCompare(String(b.nome), 'pt-BR'));
return {
baseProduct: baseProductName,
total_delta: sortedItems.reduce((sum, item) => sum + Number(item.delta_estoque || 0), 0),
sizes: sortedItems.map(item => ({
id: item.produto_id,
nome: item.nome,
delta: item.delta_estoque,
saldo: item.saldo
})),
itemIds: sortedItems.map(item => item.id)
};
});
};
const sendWhatsappCampaign = async (products, customers) => {
const productNames = products.map(product => product.baseProduct);
const productsText = formatProductList(productNames);
const allSizes = products.flatMap(product => product.sizes);
const totalDelta = products.reduce((sum, product) => sum + product.total_delta, 0);
const response = await fetch(N8N_WHATSAPP_TRIGGER_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
baseProduct: productsText,
productsText,
total_delta: totalDelta,
sizes: allSizes,
products: products.map(({ itemIds, ...product }) => product),
customers
})
body: JSON.stringify(buildWhatsappCampaignPayload(products, customers))
});
if (!response.ok) {
@@ -301,11 +231,7 @@ const processPendingStockCampaigns = async () => {
return summary;
}
const groups = rows.reduce((acc, row) => {
if (!acc[row.base_product_name]) acc[row.base_product_name] = [];
acc[row.base_product_name].push(row);
return acc;
}, {});
const groups = groupCampaignRowsByBaseProduct(rows);
const products = mapCampaignProducts(groups);
const ids = products.flatMap(product => product.itemIds);
const customers = await getTopBuyersAllTime();

View File

@@ -14,12 +14,13 @@ const upsertOrders = async (payload) => {
const insertQuery = `
INSERT INTO orders (
cliente_nome, data_pedido, valor_pedido,
cliente_nome, data_pedido, data_pedido_date, valor_pedido,
produto_id, produto_descricao, quantidade, valor_unitario, pedido_id, cliente_fone
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
ON CONFLICT (pedido_id, produto_id) DO UPDATE SET
cliente_nome = EXCLUDED.cliente_nome,
data_pedido = EXCLUDED.data_pedido,
data_pedido_date = EXCLUDED.data_pedido_date,
valor_pedido = EXCLUDED.valor_pedido,
produto_descricao = EXCLUDED.produto_descricao,
quantidade = EXCLUDED.quantidade,

View File

@@ -0,0 +1,34 @@
const assert = require('node:assert/strict');
const test = require('node:test');
const { buildDateFilter, normalizeDateParam } = require('../services/analyticsService');
test('normalizeDateParam accepts strict ISO dates', () => {
assert.equal(normalizeDateParam('2026-05-28'), '2026-05-28');
});
test('normalizeDateParam rejects non-ISO or impossible dates', () => {
assert.equal(normalizeDateParam('28/05/2026'), null);
assert.equal(normalizeDateParam('2026-02-31'), null);
assert.equal(normalizeDateParam(''), null);
});
test('buildDateFilter builds bounded date predicates', () => {
const filter = buildDateFilter({ start: '2026-05-01', end: '2026-05-28' });
assert.deepEqual(filter.params, ['2026-05-01', '2026-05-28']);
assert.equal(
filter.whereClause,
'WHERE data_pedido_date IS NOT NULL AND data_pedido_date >= $1::date AND data_pedido_date <= $2::date'
);
});
test('buildDateFilter ignores invalid bounds', () => {
const filter = buildDateFilter({ start: 'invalid', end: '2026-05-28' });
assert.deepEqual(filter.params, ['2026-05-28']);
assert.equal(
filter.whereClause,
'WHERE data_pedido_date IS NOT NULL AND data_pedido_date <= $1::date'
);
});

View File

@@ -0,0 +1,112 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const {
buildWhatsappCampaignPayload,
formatProductList,
formatProductNameForDisplay,
groupCampaignRows,
groupCampaignRowsByBaseProduct,
mapCampaignProducts
} = require('../services/campaignFormatter');
const row = (overrides) => ({
id: 1,
base_product_name: 'BASE LISA CAMISETA COR BRANCO',
produto_id: 'SKU-1',
nome: 'BASE LISA CAMISETA COR BRANCO TAMANHO - P',
saldo: 10,
delta_estoque: 10,
status: 'pending',
attempts: 0,
last_error: null,
created_at: '2026-05-28T10:00:00.000Z',
updated_at: '2026-05-28T10:00:00.000Z',
sent_at: null,
...overrides
});
test('formatProductList uses Portuguese list joining', () => {
assert.equal(formatProductList([]), '');
assert.equal(formatProductList(['BONÉ - PRETO']), 'Boné - Preto');
assert.equal(formatProductList(['BONÉ - PRETO', 'BASE BRANCA']), 'Boné - Preto e Base Branca');
assert.equal(
formatProductList(['BONÉ - PRETO', 'BASE BRANCA', 'BASE PRETA']),
'Boné - Preto, Base Branca e Base Preta'
);
});
test('formatProductNameForDisplay converts campaign product names to title case', () => {
assert.equal(
formatProductNameForDisplay('BASE LISA MOLETOM CANGURU COR PRETO'),
'Base Lisa Moletom Canguru Cor Preto'
);
assert.equal(formatProductNameForDisplay('BONÉ - BRANCO'), 'Boné - Branco');
});
test('mapCampaignProducts accumulates split deltas by base product', () => {
const groups = groupCampaignRowsByBaseProduct([
row({ id: 1, delta_estoque: 10, produto_id: 'SKU-P', nome: 'Produto Split TAMANHO - P' }),
row({ id: 2, delta_estoque: 50, produto_id: 'SKU-M', nome: 'Produto Split TAMANHO - M' }),
row({ id: 3, delta_estoque: 40, produto_id: 'SKU-G', nome: 'Produto Split TAMANHO - G' })
]);
const products = mapCampaignProducts(groups);
assert.equal(products.length, 1);
assert.equal(products[0].total_delta, 100);
assert.deepEqual(products[0].itemIds, [3, 2, 1]);
assert.deepEqual(products[0].sizes.map(size => size.id), ['SKU-G', 'SKU-M', 'SKU-P']);
});
test('buildWhatsappCampaignPayload combines multiple ready products into one message payload', () => {
const products = mapCampaignProducts(groupCampaignRowsByBaseProduct([
row({
id: 1,
base_product_name: 'BONÉ - PRETO',
produto_id: 'BONE-P',
nome: 'BONÉ - PRETO TAMANHO - P',
delta_estoque: 100,
created_at: '2026-05-28T10:00:00.000Z'
}),
row({
id: 2,
base_product_name: 'BASE LISA CAMISETA COR BRANCO',
produto_id: 'BASE-P',
nome: 'BASE LISA CAMISETA COR BRANCO TAMANHO - P',
delta_estoque: 100,
created_at: '2026-05-28T11:00:00.000Z'
})
]));
const payload = buildWhatsappCampaignPayload(products, [{ nome: 'Cliente', fone: '5511999999999' }]);
assert.equal(payload.productsText, 'Boné - Preto e Base Lisa Camiseta Cor Branco');
assert.equal(payload.baseProduct, payload.productsText);
assert.equal(payload.total_delta, 200);
assert.equal(payload.products.length, 2);
assert.equal(payload.sizes.length, 2);
assert.deepEqual(Object.keys(payload.products[0]).includes('itemIds'), false);
});
test('groupCampaignRows summarizes rows by base product and status', () => {
const groups = groupCampaignRows([
row({ id: 1, delta_estoque: 60, attempts: 1, updated_at: '2026-05-28T10:00:00.000Z' }),
row({ id: 2, delta_estoque: 40, attempts: 2, updated_at: '2026-05-28T10:05:00.000Z' }),
row({
id: 3,
base_product_name: 'BONÉ - PRETO',
status: 'failed',
delta_estoque: 100,
attempts: 3,
last_error: 'Webhook failed',
updated_at: '2026-05-28T11:00:00.000Z'
})
]);
assert.equal(groups.length, 2);
assert.equal(groups[0].baseProductName, 'BONÉ - PRETO');
assert.equal(groups[0].lastError, 'Webhook failed');
assert.equal(groups[1].baseProductName, 'BASE LISA CAMISETA COR BRANCO');
assert.equal(groups[1].totalDelta, 100);
assert.equal(groups[1].rowCount, 2);
assert.equal(groups[1].attempts, 2);
});

View File

@@ -0,0 +1,37 @@
const assert = require('node:assert/strict');
const test = require('node:test');
const { normalizeOrderDate, normalizeOrderPayload } = require('../mappers/orderMapper');
test('normalizeOrderDate accepts Brazilian display dates', () => {
assert.equal(normalizeOrderDate('28/05/2026'), '2026-05-28');
assert.equal(normalizeOrderDate('28-05-2026'), '2026-05-28');
});
test('normalizeOrderDate accepts ISO-like dates', () => {
assert.equal(normalizeOrderDate('2026-05-28'), '2026-05-28');
assert.equal(normalizeOrderDate('2026/05/28 10:30:00'), '2026-05-28');
});
test('normalizeOrderDate rejects invalid dates', () => {
assert.equal(normalizeOrderDate(''), null);
assert.equal(normalizeOrderDate('not a date'), null);
assert.equal(normalizeOrderDate('31/02/2026'), null);
});
test('normalizeOrderPayload includes normalized date without changing display date', () => {
const payload = normalizeOrderPayload({
Nome_Cliente: 'Cliente Teste',
Data_Pedido: '28/05/2026',
Valor_Pedido: '120.50',
ID_Produto: 'SKU-1',
Descricao_Produto: 'Produto',
Quantidade: '2',
Valor_Unitario: '60.25',
ID_Pedido: 'ORDER-1',
Fone_Cliente: '(16) 99999-9999'
});
assert.equal(payload[1], '28/05/2026');
assert.equal(payload[2], '2026-05-28');
});

View File

@@ -0,0 +1,29 @@
const assert = require('node:assert/strict');
const test = require('node:test');
const { getBaseProductName } = require('../mappers/stockMapper');
test('getBaseProductName strips TAMANHO suffixes', () => {
assert.equal(
getBaseProductName('BASE LISA CAMISETA COR BRANCO TAMANHO - P'),
'BASE LISA CAMISETA COR BRANCO'
);
});
test('getBaseProductName strips trailing size suffixes without removing colors', () => {
assert.equal(
getBaseProductName('BASE LISA MOLETOM CANGURU COR PRETO - M'),
'BASE LISA MOLETOM CANGURU COR PRETO'
);
assert.equal(
getBaseProductName('BASE LISA MOLETOM CANGURU COR PRETO - M/G/GG'),
'BASE LISA MOLETOM CANGURU COR PRETO'
);
assert.equal(getBaseProductName('BONÉ - BRANCO'), 'BONÉ - BRANCO');
});
test('getBaseProductName preserves etiqueta product variants', () => {
assert.equal(getBaseProductName('ETIQUETA 10X5 851UN'), 'ETIQUETA 10X5 851UN');
assert.equal(getBaseProductName('ETIQUETA BRANCA TAMANHO 08'), 'ETIQUETA BRANCA TAMANHO 08');
assert.equal(getBaseProductName('ETIQUETA BRANCA TAMANHO GG'), 'ETIQUETA BRANCA TAMANHO GG');
});

View File

@@ -1,15 +1,17 @@
import React from 'react';
import React, { Suspense } from 'react';
import { Routes, Route, Navigate, useLocation } from 'react-router-dom';
import { Loader2 } from 'lucide-react';
import Layout from './components/Layout';
import Dashboard from './pages/Dashboard';
import Products from './pages/Products';
import ProductDetails from './pages/ProductDetails';
import Clients from './pages/Clients';
import ClientDetails from './pages/ClientDetails';
import Campaigns from './pages/Campaigns';
import Login from './pages/Login';
import { isAuthenticated } from './dataService';
const Dashboard = React.lazy(() => import('./pages/Dashboard'));
const Products = React.lazy(() => import('./pages/Products'));
const ProductDetails = React.lazy(() => import('./pages/ProductDetails'));
const Clients = React.lazy(() => import('./pages/Clients'));
const ClientDetails = React.lazy(() => import('./pages/ClientDetails'));
const Campaigns = React.lazy(() => import('./pages/Campaigns'));
const Login = React.lazy(() => import('./pages/Login'));
function PrivateRoute({ children }: { children: React.ReactNode }) {
const location = useLocation();
if (!isAuthenticated()) {
@@ -18,20 +20,28 @@ function PrivateRoute({ children }: { children: React.ReactNode }) {
return children;
}
const RouteFallback = () => (
<div className="flex min-h-screen items-center justify-center bg-dark-bg text-brand-primary">
<Loader2 className="h-8 w-8 animate-spin" />
</div>
);
function App() {
return (
<Routes>
<Route path="/login" element={<Login />} />
<Route path="/" element={<Navigate to="/graph" replace />} />
<Route path="/" element={<PrivateRoute><Layout /></PrivateRoute>}>
<Route path="graph" element={<Dashboard />} />
<Route path="products" element={<Products />} />
<Route path="products/:id" element={<ProductDetails />} />
<Route path="clients" element={<Clients />} />
<Route path="clients/:name" element={<ClientDetails />} />
<Route path="campaigns" element={<Campaigns />} />
</Route>
</Routes>
<Suspense fallback={<RouteFallback />}>
<Routes>
<Route path="/login" element={<Login />} />
<Route path="/" element={<Navigate to="/graph" replace />} />
<Route path="/" element={<PrivateRoute><Layout /></PrivateRoute>}>
<Route path="graph" element={<Dashboard />} />
<Route path="products" element={<Products />} />
<Route path="products/:id" element={<ProductDetails />} />
<Route path="clients" element={<Clients />} />
<Route path="clients/:name" element={<ClientDetails />} />
<Route path="campaigns" element={<Campaigns />} />
</Route>
</Routes>
</Suspense>
);
}

View File

@@ -1,4 +1,4 @@
import type { DateRange, OrderData } from '../types';
import type { DashboardAnalytics, DateRange, OrderData } from '../types';
import { filterOrdersByDateRange, getBaseProductName, getOrderItemRevenue } from './orders';
const COLORS = [
@@ -34,6 +34,31 @@ export interface DashboardMetrics {
revenueByProduct: ChartProductMetric[];
}
export const applyDashboardColors = (metrics: DashboardAnalytics): DashboardMetrics => {
const displayProducts = Array.from(new Set([
...metrics.salesByProduct.map(product => product.name),
...metrics.revenueByProduct.map(product => product.name)
])).sort();
const productColors = displayProducts.reduce<Record<string, string>>((colors, name) => {
colors[name] = getProductColor(name);
return colors;
}, {});
return {
totalRevenue: metrics.totalRevenue,
totalOrders: metrics.totalOrders,
averageOrderValue: metrics.averageOrderValue,
salesByProduct: metrics.salesByProduct.map(product => ({
...product,
fill: productColors[product.name]
})),
revenueByProduct: metrics.revenueByProduct.map(product => ({
...product,
fill: productColors[product.name]
}))
};
};
export const buildDashboardMetrics = (ordersData: OrderData[], dateRange: DateRange): DashboardMetrics => {
const filteredData = filterOrdersByDateRange(ordersData, dateRange);
let revenue = 0;

View File

@@ -1,8 +1,15 @@
import type { DateRange, OrderData } from '../types';
import { parseOrderDate } from '../dataService';
const SIZE_SUFFIX_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}))*)$/i;
export const getBaseProductName = (description: string): string => {
return description.split(' TAMANHO')[0];
const productName = description.trim();
if (productName.toLocaleUpperCase('pt-BR').startsWith('ETIQUETA')) {
return productName;
}
return productName.split(' TAMANHO')[0].replace(SIZE_SUFFIX_PATTERN, '').trim();
};
export const getClientDisplayName = (order: OrderData): string => {

View File

@@ -1,4 +1,4 @@
import { useState, useEffect } from 'react';
import { useCallback, useState, useEffect } from 'react';
import { Outlet, Link, useLocation } from 'react-router-dom';
import { LayoutDashboard, Users, BarChart3, ChevronLeft, ChevronRight, Package, Loader2, LogOut, Megaphone } from 'lucide-react';
import type { DateRange, OrderData, StockData } from '../types';
@@ -6,6 +6,7 @@ import { fetchData, fetchStock, logout } from '../dataService';
const Layout = () => {
const location = useLocation();
const needsRawData = location.pathname.startsWith('/products') || location.pathname.startsWith('/clients');
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(() => {
return localStorage.getItem('graph_sidebar_collapsed') === 'true';
});
@@ -26,35 +27,40 @@ const Layout = () => {
const [ordersData, setOrdersData] = useState<OrderData[]>([]);
const [stockData, setStockData] = useState<StockData[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [isLoading, setIsLoading] = useState(needsRawData);
const [refreshInterval, setRefreshInterval] = useState<number>(() => {
const saved = localStorage.getItem('nexstar_refresh_interval');
return saved ? Number(saved) : 0;
});
const loadData = async (showLoading = false) => {
const loadData = useCallback(async (showLoading = false) => {
if (showLoading) setIsLoading(true);
const [data, stock] = await Promise.all([fetchData(), fetchStock()]);
setOrdersData(data);
setStockData(stock);
if (showLoading) setIsLoading(false);
};
useEffect(() => {
// The dashboard has to fetch its initial server state after mount.
// eslint-disable-next-line react-hooks/set-state-in-effect
void loadData(true);
try {
const [data, stock] = await Promise.all([fetchData(), fetchStock()]);
setOrdersData(data);
setStockData(stock);
} finally {
if (showLoading) setIsLoading(false);
}
}, []);
useEffect(() => {
if (refreshInterval === 0) return;
if (!needsRawData) return;
// Product and client pages still depend on raw orders until their API migration is complete.
// eslint-disable-next-line react-hooks/set-state-in-effect
void loadData(true);
}, [loadData, needsRawData]);
useEffect(() => {
if (refreshInterval === 0 || !needsRawData) return;
const intervalId = setInterval(() => {
loadData(false);
}, refreshInterval);
return () => clearInterval(intervalId);
}, [refreshInterval]);
}, [loadData, needsRawData, refreshInterval]);
useEffect(() => {
localStorage.setItem('nexstar_refresh_interval', refreshInterval.toString());
@@ -146,13 +152,13 @@ const Layout = () => {
{/* Content Area */}
<div className="flex-1 overflow-y-auto p-8 relative">
{isLoading ? (
<div className="flex items-center justify-center h-full">
<Loader2 className="w-8 h-8 text-brand-primary animate-spin" />
</div>
) : (
<Outlet context={{ dateRange, setDateRange, ordersData, stockData, refreshInterval, setRefreshInterval, loadData }} />
{needsRawData && isLoading && (
<div className="absolute right-8 top-8 z-10 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 shadow-sm">
<Loader2 className="h-4 w-4 animate-spin text-brand-primary" />
Atualizando dados
</div>
)}
<Outlet context={{ dateRange, setDateRange, ordersData, stockData, isDataLoading: needsRawData && isLoading, refreshInterval, setRefreshInterval, loadData }} />
</div>
</main>
</div>

View File

@@ -1,4 +1,4 @@
import type { CampaignPreview, CampaignProcessSummary, CampaignQueueSummary, OrderData, StockData } from './types';
import type { CampaignPreview, CampaignProcessSummary, CampaignQueueSummary, DashboardAnalytics, DateRange, OrderData, StockData } from './types';
const API_URL = import.meta.env.VITE_API_URL || '/api';
@@ -89,6 +89,28 @@ const authFetch = async (path: string, options: RequestInit = {}): Promise<Respo
return response;
};
const formatDateParam = (date: Date): string => {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
};
export const fetchDashboardAnalytics = async (dateRange: DateRange): Promise<DashboardAnalytics | null> => {
try {
const params = new URLSearchParams({
start: formatDateParam(dateRange.start),
end: formatDateParam(dateRange.end)
});
const response = await authFetch(`/analytics/dashboard?${params.toString()}`);
if (!response.ok) return null;
return await response.json();
} catch (error) {
console.error('Fetch dashboard analytics failed', error);
return null;
}
};
export const fetchCampaigns = async (): Promise<CampaignQueueSummary | null> => {
try {
const response = await authFetch('/campaigns');

View File

@@ -7,7 +7,7 @@ import { buildClientDetailsMetrics } from '../analytics/clients';
const ClientDetails = () => {
const { name } = useParams<{ name: string }>();
const decodedName = name ? decodeURIComponent(name) : '';
const { ordersData } = useOutletContext<{ ordersData: OrderData[] }>();
const { ordersData, isDataLoading } = useOutletContext<{ ordersData: OrderData[], isDataLoading: boolean }>();
const { groupedOrders, totalSpent, totalItems, clientPhone } = useMemo(() => {
return buildClientDetailsMetrics(ordersData, decodedName);
@@ -17,6 +17,14 @@ const ClientDetails = () => {
return new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(value);
};
if (!groupedOrders.length && isDataLoading) {
return (
<div className="text-center py-12">
<p className="text-zinc-500 dark:text-dark-muted font-medium">Carregando cliente...</p>
</div>
);
}
if (!groupedOrders.length) {
return (
<div className="text-center py-12">

View File

@@ -1,10 +1,11 @@
import { useMemo } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useOutletContext, useNavigate } from 'react-router-dom';
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, PieChart, Pie, Cell } from 'recharts';
import { DollarSign, ShoppingCart, TrendingUp } from 'lucide-react';
import { DollarSign, Loader2, ShoppingCart, TrendingUp } from 'lucide-react';
import DateRangePicker from '../components/DateRangePicker';
import type { OrderData, DateRange } from '../types';
import { buildDashboardMetrics } from '../analytics/dashboard';
import type { DashboardAnalytics, OrderData, DateRange } from '../types';
import { applyDashboardColors, buildDashboardMetrics } from '../analytics/dashboard';
import { fetchDashboardAnalytics } from '../dataService';
const formatCurrency = (value: number) => {
return new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(value);
@@ -44,18 +45,47 @@ const CustomTooltip = ({ active, payload, label, isCurrency }: CustomTooltipProp
const Dashboard = () => {
const navigate = useNavigate();
const { dateRange, setDateRange, ordersData, refreshInterval, setRefreshInterval, loadData } = useOutletContext<{
const { dateRange, setDateRange, ordersData, refreshInterval, setRefreshInterval } = useOutletContext<{
dateRange: DateRange,
setDateRange: (range: DateRange) => void,
ordersData: OrderData[],
refreshInterval: number,
setRefreshInterval: (interval: number) => void,
loadData: (showLoading?: boolean) => void
}>();
const [serverMetrics, setServerMetrics] = useState<DashboardAnalytics | null>(null);
const [isMetricsLoading, setIsMetricsLoading] = useState(true);
const loadDashboardMetrics = useCallback(async (range: DateRange) => {
setIsMetricsLoading(true);
const metrics = await fetchDashboardAnalytics(range);
setServerMetrics(metrics);
setIsMetricsLoading(false);
}, []);
useEffect(() => {
// Dashboard metrics are synchronized with the selected server-side date range.
// eslint-disable-next-line react-hooks/set-state-in-effect
void loadDashboardMetrics(dateRange);
}, [dateRange, loadDashboardMetrics]);
useEffect(() => {
if (refreshInterval === 0) return;
const intervalId = setInterval(() => {
void loadDashboardMetrics(dateRange);
}, refreshInterval);
return () => clearInterval(intervalId);
}, [dateRange, loadDashboardMetrics, refreshInterval]);
const { totalRevenue, totalOrders, averageOrderValue, salesByProduct, revenueByProduct } = useMemo(() => {
if (serverMetrics) return applyDashboardColors(serverMetrics);
return buildDashboardMetrics(ordersData, dateRange);
}, [dateRange, ordersData]);
}, [dateRange, ordersData, serverMetrics]);
const handleManualRefresh = () => {
void loadDashboardMetrics(dateRange);
};
return (
<div className="space-y-6">
@@ -69,10 +99,17 @@ const Dashboard = () => {
onChange={setDateRange}
refreshInterval={refreshInterval}
setRefreshInterval={setRefreshInterval}
onManualRefresh={() => loadData(true)}
onManualRefresh={handleManualRefresh}
/>
</div>
{isMetricsLoading && (
<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 indicadores
</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">
<div className="flex justify-between items-start">

View File

@@ -26,10 +26,11 @@ const CustomTooltip = ({ active, payload, label }: CustomTooltipProps) => {
const ProductDetails = () => {
const { id } = useParams<{ id: string }>();
const { dateRange, setDateRange, ordersData } = useOutletContext<{
const { dateRange, setDateRange, ordersData, isDataLoading } = useOutletContext<{
dateRange: DateRange,
setDateRange: (range: DateRange) => void,
ordersData: OrderData[],
isDataLoading: boolean,
refreshInterval: number,
setRefreshInterval: (interval: number) => void,
loadData: (showLoading?: boolean) => void
@@ -43,6 +44,14 @@ const ProductDetails = () => {
return new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(value);
};
if (!productInfo && isDataLoading) {
return (
<div className="text-center py-12">
<p className="text-zinc-500 dark:text-dark-muted font-medium">Carregando produto...</p>
</div>
);
}
if (!productInfo) {
return (
<div className="text-center py-12">

View File

@@ -24,6 +24,22 @@ export interface DateRange {
end: Date;
}
export interface DashboardAnalytics {
totalRevenue: number;
totalOrders: number;
averageOrderValue: number;
salesByProduct: Array<{
name: string;
id: string;
value: number;
}>;
revenueByProduct: Array<{
name: string;
id: string;
value: number;
}>;
}
export type CampaignStatus = 'pending' | 'processing' | 'sent' | 'failed' | 'skipped';
export interface CampaignQueueItem {