Compare commits
4 Commits
e2d0e94080
...
6dbc5ee190
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6dbc5ee190 | ||
|
|
cfdeb03786 | ||
|
|
fd89204973 | ||
|
|
3da299a8af |
@@ -7,22 +7,46 @@ const pool = new Pool({
|
|||||||
|
|
||||||
const initDB = async () => {
|
const initDB = async () => {
|
||||||
try {
|
try {
|
||||||
|
await pool.query(`SET TIME ZONE 'America/Sao_Paulo';`);
|
||||||
|
|
||||||
await pool.query(`
|
await pool.query(`
|
||||||
CREATE TABLE IF NOT EXISTS orders (
|
CREATE TABLE IF NOT EXISTS orders (
|
||||||
id SERIAL PRIMARY KEY,
|
id SERIAL PRIMARY KEY,
|
||||||
cliente_nome VARCHAR(255),
|
cliente_nome VARCHAR(255),
|
||||||
data_pedido VARCHAR(50),
|
data_pedido VARCHAR(50),
|
||||||
|
data_pedido_date DATE,
|
||||||
valor_pedido NUMERIC(10, 2),
|
valor_pedido NUMERIC(10, 2),
|
||||||
produto_id VARCHAR(100),
|
produto_id VARCHAR(100),
|
||||||
produto_descricao TEXT,
|
produto_descricao TEXT,
|
||||||
quantidade INTEGER,
|
quantidade INTEGER,
|
||||||
valor_unitario NUMERIC(10, 5),
|
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 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 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 => {
|
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);
|
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,
|
nome TEXT,
|
||||||
saldo INTEGER DEFAULT 0,
|
saldo INTEGER DEFAULT 0,
|
||||||
delta_estoque 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(`
|
await pool.query(`
|
||||||
CREATE TABLE IF NOT EXISTS stock_campaign_queue (
|
CREATE TABLE IF NOT EXISTS stock_campaign_queue (
|
||||||
id SERIAL PRIMARY KEY,
|
id SERIAL PRIMARY KEY,
|
||||||
@@ -49,15 +79,25 @@ const initDB = async () => {
|
|||||||
status VARCHAR(20) DEFAULT 'pending',
|
status VARCHAR(20) DEFAULT 'pending',
|
||||||
attempts INTEGER DEFAULT 0,
|
attempts INTEGER DEFAULT 0,
|
||||||
last_error TEXT,
|
last_error TEXT,
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||||
sent_at 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(`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_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);`);
|
||||||
|
|
||||||
console.log('Database initialized successfully.');
|
console.log('Database initialized successfully.');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -11,14 +11,40 @@ const formatOrderRow = (row) => ({
|
|||||||
Fone_Cliente: row.cliente_fone
|
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 normalizeOrderPayload = (item) => {
|
||||||
const fallbackId = `${item.Nome_Cliente}_${item.Data_Pedido}_${item.Valor_Pedido}`;
|
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 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 fone = item.Fone_Cliente || item.fone || item.celular || '';
|
||||||
|
const orderDate = item.Data_Pedido || '';
|
||||||
|
|
||||||
return [
|
return [
|
||||||
item.Nome_Cliente || 'Unknown',
|
item.Nome_Cliente || 'Unknown',
|
||||||
item.Data_Pedido || '',
|
orderDate,
|
||||||
|
normalizeOrderDate(orderDate),
|
||||||
parseFloat(item.Valor_Pedido) || 0,
|
parseFloat(item.Valor_Pedido) || 0,
|
||||||
item.ID_Produto || '',
|
item.ID_Produto || '',
|
||||||
item.Descricao_Produto || '',
|
item.Descricao_Produto || '',
|
||||||
@@ -31,5 +57,6 @@ const normalizeOrderPayload = (item) => {
|
|||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
formatOrderRow,
|
formatOrderRow,
|
||||||
|
normalizeOrderDate,
|
||||||
normalizeOrderPayload
|
normalizeOrderPayload
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node index.js",
|
"start": "node index.js",
|
||||||
"test": "echo \"Error: no test specified\" && exit 1"
|
"test": "node --test"
|
||||||
},
|
},
|
||||||
"keywords": [],
|
"keywords": [],
|
||||||
"author": "",
|
"author": "",
|
||||||
|
|||||||
43
backend/routes/analyticsRoutes.js
Normal file
43
backend/routes/analyticsRoutes.js
Normal 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;
|
||||||
@@ -6,6 +6,7 @@ const dataRoutes = require('./routes/dataRoutes');
|
|||||||
const stockRoutes = require('./routes/stockRoutes');
|
const stockRoutes = require('./routes/stockRoutes');
|
||||||
const campaignRoutes = require('./routes/campaignRoutes');
|
const campaignRoutes = require('./routes/campaignRoutes');
|
||||||
const internalRoutes = require('./routes/internalRoutes');
|
const internalRoutes = require('./routes/internalRoutes');
|
||||||
|
const analyticsRoutes = require('./routes/analyticsRoutes');
|
||||||
|
|
||||||
const createApp = () => {
|
const createApp = () => {
|
||||||
const app = express();
|
const app = express();
|
||||||
@@ -17,6 +18,7 @@ const createApp = () => {
|
|||||||
app.use('/api', dataRoutes);
|
app.use('/api', dataRoutes);
|
||||||
app.use('/api', stockRoutes);
|
app.use('/api', stockRoutes);
|
||||||
app.use('/api', campaignRoutes);
|
app.use('/api', campaignRoutes);
|
||||||
|
app.use('/api', analyticsRoutes);
|
||||||
app.use('/api/internal', internalRoutes);
|
app.use('/api/internal', internalRoutes);
|
||||||
|
|
||||||
return app;
|
return app;
|
||||||
|
|||||||
177
backend/services/analyticsService.js
Normal file
177
backend/services/analyticsService.js
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
const { pool } = require('../db');
|
||||||
|
|
||||||
|
const PRODUCT_NAME_SQL = "NULLIF(TRIM(split_part(COALESCE(produto_descricao, 'Unknown'), ' TAMANHO', 1)), '')";
|
||||||
|
|
||||||
|
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
|
||||||
|
};
|
||||||
92
backend/services/campaignFormatter.js
Normal file
92
backend/services/campaignFormatter.js
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
const formatProductList = (productNames) => {
|
||||||
|
if (productNames.length <= 2) {
|
||||||
|
return productNames.join(' e ');
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${productNames.slice(0, -1).join(', ')} e ${productNames[productNames.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: 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,
|
||||||
|
formatProductList,
|
||||||
|
groupCampaignRows,
|
||||||
|
groupCampaignRowsByBaseProduct,
|
||||||
|
mapCampaignProducts
|
||||||
|
};
|
||||||
@@ -1,5 +1,12 @@
|
|||||||
const { pool } = require('../db');
|
const { pool } = require('../db');
|
||||||
const { N8N_WHATSAPP_TRIGGER_URL } = require('../config');
|
const { N8N_WHATSAPP_TRIGGER_URL } = require('../config');
|
||||||
|
const {
|
||||||
|
buildWhatsappCampaignPayload,
|
||||||
|
formatProductList,
|
||||||
|
groupCampaignRows,
|
||||||
|
groupCampaignRowsByBaseProduct,
|
||||||
|
mapCampaignProducts
|
||||||
|
} = require('./campaignFormatter');
|
||||||
|
|
||||||
const TOP_BUYERS_LIMIT = 100;
|
const TOP_BUYERS_LIMIT = 100;
|
||||||
const MAX_CAMPAIGN_ATTEMPTS = 3;
|
const MAX_CAMPAIGN_ATTEMPTS = 3;
|
||||||
@@ -109,37 +116,6 @@ const getCampaignQueueRows = async () => {
|
|||||||
return result.rows;
|
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 getCampaignQueueSummary = async () => {
|
||||||
const rows = await getCampaignQueueRows();
|
const rows = await getCampaignQueueRows();
|
||||||
return {
|
return {
|
||||||
@@ -158,11 +134,7 @@ const getCampaignPreview = async () => {
|
|||||||
AND attempts < $1
|
AND attempts < $1
|
||||||
ORDER BY created_at ASC, id ASC;
|
ORDER BY created_at ASC, id ASC;
|
||||||
`, [MAX_CAMPAIGN_ATTEMPTS]);
|
`, [MAX_CAMPAIGN_ATTEMPTS]);
|
||||||
const groups = result.rows.reduce((acc, row) => {
|
const groups = groupCampaignRowsByBaseProduct(result.rows);
|
||||||
if (!acc[row.base_product_name]) acc[row.base_product_name] = [];
|
|
||||||
acc[row.base_product_name].push(row);
|
|
||||||
return acc;
|
|
||||||
}, {});
|
|
||||||
const readyGroups = {};
|
const readyGroups = {};
|
||||||
const belowThresholdGroups = {};
|
const belowThresholdGroups = {};
|
||||||
|
|
||||||
@@ -233,53 +205,11 @@ const updateCampaignItemsStatus = async (ids, status, errorMessage = null) => {
|
|||||||
`, [status, errorMessage, ids]);
|
`, [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 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, {
|
const response = await fetch(N8N_WHATSAPP_TRIGGER_URL, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify(buildWhatsappCampaignPayload(products, customers))
|
||||||
baseProduct: productsText,
|
|
||||||
productsText,
|
|
||||||
total_delta: totalDelta,
|
|
||||||
sizes: allSizes,
|
|
||||||
products: products.map(({ itemIds, ...product }) => product),
|
|
||||||
customers
|
|
||||||
})
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -301,11 +231,7 @@ const processPendingStockCampaigns = async () => {
|
|||||||
return summary;
|
return summary;
|
||||||
}
|
}
|
||||||
|
|
||||||
const groups = rows.reduce((acc, row) => {
|
const groups = groupCampaignRowsByBaseProduct(rows);
|
||||||
if (!acc[row.base_product_name]) acc[row.base_product_name] = [];
|
|
||||||
acc[row.base_product_name].push(row);
|
|
||||||
return acc;
|
|
||||||
}, {});
|
|
||||||
const products = mapCampaignProducts(groups);
|
const products = mapCampaignProducts(groups);
|
||||||
const ids = products.flatMap(product => product.itemIds);
|
const ids = products.flatMap(product => product.itemIds);
|
||||||
const customers = await getTopBuyersAllTime();
|
const customers = await getTopBuyersAllTime();
|
||||||
|
|||||||
@@ -14,12 +14,13 @@ const upsertOrders = async (payload) => {
|
|||||||
|
|
||||||
const insertQuery = `
|
const insertQuery = `
|
||||||
INSERT INTO orders (
|
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
|
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
|
ON CONFLICT (pedido_id, produto_id) DO UPDATE SET
|
||||||
cliente_nome = EXCLUDED.cliente_nome,
|
cliente_nome = EXCLUDED.cliente_nome,
|
||||||
data_pedido = EXCLUDED.data_pedido,
|
data_pedido = EXCLUDED.data_pedido,
|
||||||
|
data_pedido_date = EXCLUDED.data_pedido_date,
|
||||||
valor_pedido = EXCLUDED.valor_pedido,
|
valor_pedido = EXCLUDED.valor_pedido,
|
||||||
produto_descricao = EXCLUDED.produto_descricao,
|
produto_descricao = EXCLUDED.produto_descricao,
|
||||||
quantidade = EXCLUDED.quantidade,
|
quantidade = EXCLUDED.quantidade,
|
||||||
|
|||||||
34
backend/test/analyticsService.test.js
Normal file
34
backend/test/analyticsService.test.js
Normal 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'
|
||||||
|
);
|
||||||
|
});
|
||||||
103
backend/test/campaignFormatter.test.js
Normal file
103
backend/test/campaignFormatter.test.js
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const {
|
||||||
|
buildWhatsappCampaignPayload,
|
||||||
|
formatProductList,
|
||||||
|
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('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);
|
||||||
|
});
|
||||||
37
backend/test/orderMapper.test.js
Normal file
37
backend/test/orderMapper.test.js
Normal 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');
|
||||||
|
});
|
||||||
26
src/App.tsx
26
src/App.tsx
@@ -1,15 +1,17 @@
|
|||||||
import React from 'react';
|
import React, { Suspense } from 'react';
|
||||||
import { Routes, Route, Navigate, useLocation } from 'react-router-dom';
|
import { Routes, Route, Navigate, useLocation } from 'react-router-dom';
|
||||||
|
import { Loader2 } from 'lucide-react';
|
||||||
import Layout from './components/Layout';
|
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';
|
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 }) {
|
function PrivateRoute({ children }: { children: React.ReactNode }) {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
if (!isAuthenticated()) {
|
if (!isAuthenticated()) {
|
||||||
@@ -18,8 +20,15 @@ function PrivateRoute({ children }: { children: React.ReactNode }) {
|
|||||||
return children;
|
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() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
|
<Suspense fallback={<RouteFallback />}>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/login" element={<Login />} />
|
<Route path="/login" element={<Login />} />
|
||||||
<Route path="/" element={<Navigate to="/graph" replace />} />
|
<Route path="/" element={<Navigate to="/graph" replace />} />
|
||||||
@@ -32,6 +41,7 @@ function App() {
|
|||||||
<Route path="campaigns" element={<Campaigns />} />
|
<Route path="campaigns" element={<Campaigns />} />
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
|
</Suspense>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user