refactor backend and persist stock campaign queue
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m32s
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m32s
This commit is contained in:
40
backend/auth.js
Normal file
40
backend/auth.js
Normal file
@@ -0,0 +1,40 @@
|
||||
const jwt = require('jsonwebtoken');
|
||||
const { ADMIN_EMAIL, ADMIN_PASSWORD, API_KEY, JWT_SECRET } = require('./config');
|
||||
|
||||
const verifyToken = (req, res, next) => {
|
||||
const authHeader = req.headers.authorization;
|
||||
if (!authHeader) return res.status(403).json({ error: 'No token provided' });
|
||||
|
||||
const token = authHeader.split(' ')[1];
|
||||
if (!token) return res.status(403).json({ error: 'Malformed token' });
|
||||
|
||||
jwt.verify(token, JWT_SECRET, (err, decoded) => {
|
||||
if (err) return res.status(401).json({ error: 'Unauthorized' });
|
||||
req.user = decoded;
|
||||
next();
|
||||
});
|
||||
};
|
||||
|
||||
const authenticateAPIKey = (req, res, next) => {
|
||||
const apiKey = req.headers['x-api-key'];
|
||||
if (apiKey === API_KEY) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(401).json({ error: 'Unauthorized: Invalid API Key' });
|
||||
};
|
||||
|
||||
const login = (email, password) => {
|
||||
if (email !== ADMIN_EMAIL || password !== ADMIN_PASSWORD) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return jwt.sign({ email }, JWT_SECRET, { expiresIn: '24h' });
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
verifyToken,
|
||||
authenticateAPIKey,
|
||||
login
|
||||
};
|
||||
11
backend/config.js
Normal file
11
backend/config.js
Normal file
@@ -0,0 +1,11 @@
|
||||
require('dotenv').config();
|
||||
|
||||
module.exports = {
|
||||
PORT: process.env.PORT || 3004,
|
||||
API_KEY: process.env.API_KEY || 'nexstar_secret_key_123',
|
||||
ADMIN_EMAIL: process.env.ADMIN_EMAIL || 'admin@admin.com',
|
||||
ADMIN_PASSWORD: process.env.ADMIN_PASSWORD || 'admin123',
|
||||
JWT_SECRET: process.env.JWT_SECRET || 'super_secret_jwt_key_123',
|
||||
DATABASE_URL: process.env.DATABASE_URL || 'postgres://graphuser:graphpassword@localhost:5432/graphdb',
|
||||
N8N_WHATSAPP_TRIGGER_URL: process.env.N8N_WHATSAPP_TRIGGER_URL || 'http://localhost:5678/webhook/whatsapp'
|
||||
};
|
||||
72
backend/db.js
Normal file
72
backend/db.js
Normal file
@@ -0,0 +1,72 @@
|
||||
const { Pool } = require('pg');
|
||||
const { DATABASE_URL } = require('./config');
|
||||
|
||||
const pool = new Pool({
|
||||
connectionString: DATABASE_URL
|
||||
});
|
||||
|
||||
const initDB = async () => {
|
||||
try {
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS orders (
|
||||
id SERIAL PRIMARY KEY,
|
||||
cliente_nome VARCHAR(255),
|
||||
data_pedido VARCHAR(50),
|
||||
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
|
||||
);
|
||||
`);
|
||||
|
||||
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(`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);
|
||||
});
|
||||
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS stock (
|
||||
produto_id VARCHAR(100) PRIMARY KEY,
|
||||
nome TEXT,
|
||||
saldo INTEGER DEFAULT 0,
|
||||
delta_estoque INTEGER DEFAULT 0,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
`);
|
||||
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS stock_campaign_queue (
|
||||
id SERIAL PRIMARY KEY,
|
||||
base_product_name TEXT NOT NULL,
|
||||
produto_id VARCHAR(100) NOT NULL,
|
||||
nome TEXT NOT NULL,
|
||||
saldo INTEGER DEFAULT 0,
|
||||
delta_estoque INTEGER DEFAULT 0,
|
||||
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
|
||||
);
|
||||
`);
|
||||
|
||||
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);`);
|
||||
|
||||
console.log('Database initialized successfully.');
|
||||
} catch (err) {
|
||||
console.error('Failed to initialize database:', err);
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
pool,
|
||||
initDB
|
||||
};
|
||||
339
backend/index.js
339
backend/index.js
@@ -1,332 +1,19 @@
|
||||
const express = require('express');
|
||||
const cors = require('cors');
|
||||
const bodyParser = require('body-parser');
|
||||
const { Pool } = require('pg');
|
||||
const jwt = require('jsonwebtoken');
|
||||
require('dotenv').config();
|
||||
const { createApp } = require('./server');
|
||||
const { initDB } = require('./db');
|
||||
const { PORT } = require('./config');
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3004;
|
||||
const API_KEY = process.env.API_KEY || "nexstar_secret_key_123";
|
||||
const start = async () => {
|
||||
await initDB();
|
||||
|
||||
// Admin Credentials
|
||||
const ADMIN_EMAIL = process.env.ADMIN_EMAIL || 'admin@admin.com';
|
||||
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || 'admin123';
|
||||
const JWT_SECRET = process.env.JWT_SECRET || 'super_secret_jwt_key_123';
|
||||
|
||||
app.use(cors());
|
||||
app.use(bodyParser.json());
|
||||
|
||||
// PostgreSQL Connection Pool
|
||||
const pool = new Pool({
|
||||
connectionString: process.env.DATABASE_URL || 'postgres://graphuser:graphpassword@localhost:5432/graphdb',
|
||||
});
|
||||
|
||||
// Initialize Database Table
|
||||
const initDB = async () => {
|
||||
try {
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS orders (
|
||||
id SERIAL PRIMARY KEY,
|
||||
cliente_nome VARCHAR(255),
|
||||
data_pedido VARCHAR(50),
|
||||
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
|
||||
);
|
||||
`);
|
||||
|
||||
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(`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);
|
||||
});
|
||||
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS stock (
|
||||
produto_id VARCHAR(100) PRIMARY KEY,
|
||||
nome TEXT,
|
||||
saldo INTEGER DEFAULT 0,
|
||||
delta_estoque INTEGER DEFAULT 0,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
`);
|
||||
|
||||
console.log("Database initialized successfully.");
|
||||
} catch (err) {
|
||||
console.error("Failed to initialize database:", err);
|
||||
}
|
||||
};
|
||||
|
||||
initDB();
|
||||
|
||||
// Middleware for Frontend Authentication
|
||||
const verifyToken = (req, res, next) => {
|
||||
const authHeader = req.headers['authorization'];
|
||||
if (!authHeader) return res.status(403).json({ error: 'No token provided' });
|
||||
|
||||
const token = authHeader.split(' ')[1];
|
||||
if (!token) return res.status(403).json({ error: 'Malformed token' });
|
||||
|
||||
jwt.verify(token, JWT_SECRET, (err, decoded) => {
|
||||
if (err) return res.status(401).json({ error: 'Unauthorized' });
|
||||
req.user = decoded;
|
||||
next();
|
||||
const app = createApp();
|
||||
app.listen(PORT, '0.0.0.0', () => {
|
||||
console.log(`Nexstar Backend running at http://localhost:${PORT}`);
|
||||
console.log(`Endpoint for n8n: POST http://localhost:${PORT}/api/data`);
|
||||
console.log(`Scheduled campaign processor: POST http://localhost:${PORT}/api/internal/process-stock-campaigns`);
|
||||
});
|
||||
};
|
||||
|
||||
// Login Endpoint
|
||||
app.post('/api/login', (req, res) => {
|
||||
const { email, password } = req.body;
|
||||
if (email === ADMIN_EMAIL && password === ADMIN_PASSWORD) {
|
||||
const token = jwt.sign({ email }, JWT_SECRET, { expiresIn: '24h' });
|
||||
res.json({ token });
|
||||
} else {
|
||||
res.status(401).json({ error: 'Invalid credentials' });
|
||||
}
|
||||
start().catch((error) => {
|
||||
console.error('Failed to start backend:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
// Helper to format rows to match the old JSON structure for the frontend
|
||||
const formatRow = (row) => ({
|
||||
Nome_Cliente: row.cliente_nome,
|
||||
Data_Pedido: row.data_pedido,
|
||||
Valor_Pedido: parseFloat(row.valor_pedido),
|
||||
ID_Produto: row.produto_id,
|
||||
Descricao_Produto: row.produto_descricao,
|
||||
Quantidade: row.quantidade,
|
||||
Valor_Unitario: parseFloat(row.valor_unitario),
|
||||
Recebido_Em: row.created_at,
|
||||
ID_Pedido: row.pedido_id,
|
||||
Fone_Cliente: row.cliente_fone
|
||||
});
|
||||
|
||||
// GET data (for the frontend)
|
||||
app.get('/api/data', verifyToken, async (req, res) => {
|
||||
try {
|
||||
const result = await pool.query('SELECT * FROM orders ORDER BY id DESC');
|
||||
const formattedData = result.rows.map(formatRow);
|
||||
res.json(formattedData);
|
||||
} catch (error) {
|
||||
console.error("Error fetching data:", error);
|
||||
res.status(500).json({ error: 'Internal Server Error' });
|
||||
}
|
||||
});
|
||||
|
||||
// GET stock (for the frontend)
|
||||
app.get('/api/stock', verifyToken, async (req, res) => {
|
||||
try {
|
||||
const result = await pool.query('SELECT * FROM stock');
|
||||
res.json(result.rows);
|
||||
} catch (error) {
|
||||
console.error("Error fetching stock:", error);
|
||||
res.status(500).json({ error: 'Internal Server Error' });
|
||||
}
|
||||
});
|
||||
|
||||
// POST data (for n8n) - Protected by API_KEY internally or via middleware if needed
|
||||
// Leaving it as it was, checking API_KEY manually? Wait, the previous version didn't actually use 'authenticate' middleware on the POST!
|
||||
// Let's add the authenticate middleware to the POST endpoint.
|
||||
const authenticateAPIKey = (req, res, next) => {
|
||||
const apiKey = req.headers['x-api-key'];
|
||||
if (apiKey === API_KEY) {
|
||||
next();
|
||||
} else {
|
||||
res.status(401).json({ error: 'Unauthorized: Invalid API Key' });
|
||||
}
|
||||
};
|
||||
|
||||
app.post('/api/data', authenticateAPIKey, async (req, res) => {
|
||||
// Respond IMMEDIATELY to prevent slowing down n8n / WhatsApp flows
|
||||
res.status(201).json({ message: 'Data received, processing in background' });
|
||||
|
||||
const newData = req.body;
|
||||
const payload = Array.isArray(newData) ? newData : [newData];
|
||||
|
||||
// Process asynchronously
|
||||
(async () => {
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
|
||||
const insertQuery = `
|
||||
INSERT INTO orders (
|
||||
cliente_nome, data_pedido, valor_pedido,
|
||||
produto_id, produto_descricao, quantidade, valor_unitario, pedido_id, cliente_fone
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
ON CONFLICT (pedido_id, produto_id) DO UPDATE SET
|
||||
cliente_nome = EXCLUDED.cliente_nome,
|
||||
data_pedido = EXCLUDED.data_pedido,
|
||||
valor_pedido = EXCLUDED.valor_pedido,
|
||||
produto_descricao = EXCLUDED.produto_descricao,
|
||||
quantidade = EXCLUDED.quantidade,
|
||||
valor_unitario = EXCLUDED.valor_unitario,
|
||||
cliente_fone = EXCLUDED.cliente_fone,
|
||||
created_at = CURRENT_TIMESTAMP
|
||||
`;
|
||||
|
||||
for (const item of payload) {
|
||||
// Handle potential missing fields gracefully
|
||||
// If there is no explicit ID, create a composite ID using Name + Date + Value to prevent squashing historical data
|
||||
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 values = [
|
||||
item.Nome_Cliente || 'Unknown',
|
||||
item.Data_Pedido || '',
|
||||
parseFloat(item.Valor_Pedido) || 0,
|
||||
item.ID_Produto || '',
|
||||
item.Descricao_Produto || '',
|
||||
parseInt(item.Quantidade) || 0,
|
||||
parseFloat(item.Valor_Unitario) || 0,
|
||||
String(orderId),
|
||||
String(fone)
|
||||
];
|
||||
await client.query(insertQuery, values);
|
||||
}
|
||||
|
||||
await client.query('COMMIT');
|
||||
} catch (error) {
|
||||
await client.query('ROLLBACK');
|
||||
console.error("Database insert error:", error);
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
})();
|
||||
});
|
||||
|
||||
// In-memory Waiting Room for WhatsApp Marketing
|
||||
const waitingRoom = {};
|
||||
const N8N_WHATSAPP_TRIGGER_URL = process.env.N8N_WHATSAPP_TRIGGER_URL || 'http://localhost:5678/webhook/whatsapp';
|
||||
|
||||
// POST stock (for n8n)
|
||||
app.post('/api/stock', authenticateAPIKey, async (req, res) => {
|
||||
res.status(201).json({ message: 'Stock data received, processing in background' });
|
||||
|
||||
const newData = req.body;
|
||||
const payload = Array.isArray(newData) ? newData : [newData];
|
||||
|
||||
(async () => {
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
|
||||
const insertQuery = `
|
||||
INSERT INTO stock (produto_id, nome, saldo, delta_estoque)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (produto_id) DO UPDATE SET
|
||||
nome = EXCLUDED.nome,
|
||||
saldo = EXCLUDED.saldo,
|
||||
delta_estoque = EXCLUDED.delta_estoque,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
`;
|
||||
|
||||
for (const item of payload) {
|
||||
const idProduto = item.idProduto || item.ID_Produto || '';
|
||||
if (!idProduto) continue;
|
||||
|
||||
const delta = parseInt(item.delta_estoque) || 0;
|
||||
const nomeStr = item.nome || item.Descricao_Produto || 'Unknown';
|
||||
|
||||
const values = [
|
||||
String(idProduto),
|
||||
nomeStr,
|
||||
parseInt(item.saldo) || 0,
|
||||
delta
|
||||
];
|
||||
await client.query(insertQuery, values);
|
||||
|
||||
// Waiting Room / Debounce Logic
|
||||
if (delta >= 100) {
|
||||
const baseProductName = nomeStr.split(' TAMANHO')[0].trim();
|
||||
|
||||
if (!waitingRoom[baseProductName]) {
|
||||
console.log(`[Waiting Room] Nova entrada: ${baseProductName}. Iniciando timer de 30m...`);
|
||||
waitingRoom[baseProductName] = {
|
||||
total_delta: 0,
|
||||
items: [],
|
||||
timeout: null
|
||||
};
|
||||
} else {
|
||||
console.log(`[Waiting Room] Atualização: ${baseProductName}. Timer reiniciado (30m).`);
|
||||
}
|
||||
|
||||
waitingRoom[baseProductName].total_delta += delta;
|
||||
waitingRoom[baseProductName].items.push({
|
||||
id: String(idProduto),
|
||||
nome: nomeStr,
|
||||
delta: delta,
|
||||
saldo: parseInt(item.saldo) || 0
|
||||
});
|
||||
|
||||
if (waitingRoom[baseProductName].timeout) {
|
||||
clearTimeout(waitingRoom[baseProductName].timeout);
|
||||
}
|
||||
|
||||
waitingRoom[baseProductName].timeout = setTimeout(async () => {
|
||||
try {
|
||||
const aggData = waitingRoom[baseProductName];
|
||||
delete waitingRoom[baseProductName]; // Clear from waiting room
|
||||
|
||||
console.log(`[Waiting Room] Timer finalizado para ${baseProductName}. Buscando top buyers...`);
|
||||
|
||||
// Find Top 100 buyers for this Base Product
|
||||
const topBuyersQuery = `
|
||||
SELECT
|
||||
MAX(cliente_nome) as nome,
|
||||
cliente_fone as fone,
|
||||
SUM(quantidade) as total_comprado
|
||||
FROM orders
|
||||
WHERE produto_descricao LIKE $1
|
||||
AND cliente_fone IS NOT NULL
|
||||
AND cliente_fone != ''
|
||||
GROUP BY cliente_fone
|
||||
ORDER BY total_comprado DESC
|
||||
LIMIT 100;
|
||||
`;
|
||||
const topBuyersResult = await pool.query(topBuyersQuery, [`${baseProductName}%`]);
|
||||
|
||||
// Only trigger if we actually have buyers
|
||||
if (topBuyersResult.rows.length > 0) {
|
||||
console.log(`[Waiting Room] Disparando webhook do WhatsApp para ${topBuyersResult.rows.length} clientes (${baseProductName})...`);
|
||||
fetch(N8N_WHATSAPP_TRIGGER_URL, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
baseProduct: baseProductName,
|
||||
total_delta: aggData.total_delta,
|
||||
sizes: aggData.items,
|
||||
customers: topBuyersResult.rows
|
||||
})
|
||||
}).then(res => {
|
||||
if(res.ok) console.log(`[Waiting Room] Sucesso! Webhook disparado.`);
|
||||
else console.log(`[Waiting Room] Aviso: Webhook retornou status ${res.status}`);
|
||||
}).catch(err => console.error("Failed to trigger WhatsApp webhook:", err));
|
||||
} else {
|
||||
console.log(`[Waiting Room] Nenhum cliente encontrado com telefone válido para ${baseProductName}. Webhook cancelado.`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Error in Waiting Room timeout:", err);
|
||||
}
|
||||
}, 1800000); // 30 minutes
|
||||
}
|
||||
}
|
||||
|
||||
await client.query('COMMIT');
|
||||
} catch (error) {
|
||||
await client.query('ROLLBACK');
|
||||
console.error("Database stock insert error:", error);
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
})();
|
||||
});
|
||||
|
||||
app.listen(PORT, '0.0.0.0', () => {
|
||||
console.log(`Nexstar Backend running at http://localhost:${PORT}`);
|
||||
console.log(`Endpoint for n8n: POST http://localhost:${PORT}/api/data`);
|
||||
});
|
||||
35
backend/mappers/orderMapper.js
Normal file
35
backend/mappers/orderMapper.js
Normal file
@@ -0,0 +1,35 @@
|
||||
const formatOrderRow = (row) => ({
|
||||
Nome_Cliente: row.cliente_nome,
|
||||
Data_Pedido: row.data_pedido,
|
||||
Valor_Pedido: parseFloat(row.valor_pedido),
|
||||
ID_Produto: row.produto_id,
|
||||
Descricao_Produto: row.produto_descricao,
|
||||
Quantidade: row.quantidade,
|
||||
Valor_Unitario: parseFloat(row.valor_unitario),
|
||||
Recebido_Em: row.created_at,
|
||||
ID_Pedido: row.pedido_id,
|
||||
Fone_Cliente: row.cliente_fone
|
||||
});
|
||||
|
||||
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 || '';
|
||||
|
||||
return [
|
||||
item.Nome_Cliente || 'Unknown',
|
||||
item.Data_Pedido || '',
|
||||
parseFloat(item.Valor_Pedido) || 0,
|
||||
item.ID_Produto || '',
|
||||
item.Descricao_Produto || '',
|
||||
parseInt(item.Quantidade, 10) || 0,
|
||||
parseFloat(item.Valor_Unitario) || 0,
|
||||
String(orderId),
|
||||
String(fone)
|
||||
];
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
formatOrderRow,
|
||||
normalizeOrderPayload
|
||||
};
|
||||
19
backend/mappers/stockMapper.js
Normal file
19
backend/mappers/stockMapper.js
Normal file
@@ -0,0 +1,19 @@
|
||||
const getBaseProductName = (name) => String(name || 'Unknown').split(' TAMANHO')[0].trim();
|
||||
|
||||
const normalizeStockPayload = (item) => {
|
||||
const produtoId = item.idProduto || item.ID_Produto || '';
|
||||
const nome = item.nome || item.Descricao_Produto || 'Unknown';
|
||||
|
||||
return {
|
||||
produtoId: String(produtoId),
|
||||
nome,
|
||||
baseProductName: getBaseProductName(nome),
|
||||
saldo: parseInt(item.saldo, 10) || 0,
|
||||
deltaEstoque: parseInt(item.delta_estoque, 10) || 0
|
||||
};
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
getBaseProductName,
|
||||
normalizeStockPayload
|
||||
};
|
||||
18
backend/routes/authRoutes.js
Normal file
18
backend/routes/authRoutes.js
Normal file
@@ -0,0 +1,18 @@
|
||||
const express = require('express');
|
||||
const { login } = require('../auth');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.post('/login', (req, res) => {
|
||||
const { email, password } = req.body;
|
||||
const token = login(email, password);
|
||||
|
||||
if (!token) {
|
||||
res.status(401).json({ error: 'Invalid credentials' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({ token });
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
26
backend/routes/dataRoutes.js
Normal file
26
backend/routes/dataRoutes.js
Normal file
@@ -0,0 +1,26 @@
|
||||
const express = require('express');
|
||||
const { authenticateAPIKey, verifyToken } = require('../auth');
|
||||
const { listOrders, upsertOrders } = require('../services/ordersService');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.get('/data', verifyToken, async (req, res) => {
|
||||
try {
|
||||
res.json(await listOrders());
|
||||
} catch (error) {
|
||||
console.error('Error fetching data:', error);
|
||||
res.status(500).json({ error: 'Internal Server Error' });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/data', authenticateAPIKey, async (req, res) => {
|
||||
res.status(201).json({ message: 'Data received, processing in background' });
|
||||
|
||||
const payload = Array.isArray(req.body) ? req.body : [req.body];
|
||||
|
||||
upsertOrders(payload).catch((error) => {
|
||||
console.error('Database insert error:', error);
|
||||
});
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
17
backend/routes/internalRoutes.js
Normal file
17
backend/routes/internalRoutes.js
Normal file
@@ -0,0 +1,17 @@
|
||||
const express = require('express');
|
||||
const { authenticateAPIKey } = require('../auth');
|
||||
const { processPendingStockCampaigns } = require('../services/campaignService');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.post('/process-stock-campaigns', authenticateAPIKey, async (req, res) => {
|
||||
try {
|
||||
const summary = await processPendingStockCampaigns();
|
||||
res.json(summary);
|
||||
} catch (error) {
|
||||
console.error('Error processing stock campaigns:', error);
|
||||
res.status(500).json({ error: 'Internal Server Error' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
26
backend/routes/stockRoutes.js
Normal file
26
backend/routes/stockRoutes.js
Normal file
@@ -0,0 +1,26 @@
|
||||
const express = require('express');
|
||||
const { authenticateAPIKey, verifyToken } = require('../auth');
|
||||
const { listStock, upsertStockItems } = require('../services/stockService');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.get('/stock', verifyToken, async (req, res) => {
|
||||
try {
|
||||
res.json(await listStock());
|
||||
} catch (error) {
|
||||
console.error('Error fetching stock:', error);
|
||||
res.status(500).json({ error: 'Internal Server Error' });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/stock', authenticateAPIKey, async (req, res) => {
|
||||
res.status(201).json({ message: 'Stock data received, processing in background' });
|
||||
|
||||
const payload = Array.isArray(req.body) ? req.body : [req.body];
|
||||
|
||||
upsertStockItems(payload).catch((error) => {
|
||||
console.error('Database stock insert error:', error);
|
||||
});
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
25
backend/server.js
Normal file
25
backend/server.js
Normal file
@@ -0,0 +1,25 @@
|
||||
const express = require('express');
|
||||
const cors = require('cors');
|
||||
const bodyParser = require('body-parser');
|
||||
const authRoutes = require('./routes/authRoutes');
|
||||
const dataRoutes = require('./routes/dataRoutes');
|
||||
const stockRoutes = require('./routes/stockRoutes');
|
||||
const internalRoutes = require('./routes/internalRoutes');
|
||||
|
||||
const createApp = () => {
|
||||
const app = express();
|
||||
|
||||
app.use(cors());
|
||||
app.use(bodyParser.json());
|
||||
|
||||
app.use('/api', authRoutes);
|
||||
app.use('/api', dataRoutes);
|
||||
app.use('/api', stockRoutes);
|
||||
app.use('/api/internal', internalRoutes);
|
||||
|
||||
return app;
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
createApp
|
||||
};
|
||||
158
backend/services/campaignService.js
Normal file
158
backend/services/campaignService.js
Normal file
@@ -0,0 +1,158 @@
|
||||
const { pool } = require('../db');
|
||||
const { N8N_WHATSAPP_TRIGGER_URL } = require('../config');
|
||||
|
||||
const TOP_BUYERS_LIMIT = 100;
|
||||
const MAX_CAMPAIGN_ATTEMPTS = 3;
|
||||
|
||||
const enqueueStockCampaignItem = async (client, item) => {
|
||||
const query = `
|
||||
INSERT INTO stock_campaign_queue (
|
||||
base_product_name, produto_id, nome, saldo, delta_estoque
|
||||
) VALUES ($1, $2, $3, $4, $5)
|
||||
`;
|
||||
|
||||
await client.query(query, [
|
||||
item.baseProductName,
|
||||
item.produtoId,
|
||||
item.nome,
|
||||
item.saldo,
|
||||
item.deltaEstoque
|
||||
]);
|
||||
};
|
||||
|
||||
const getTopBuyersAllTime = async () => {
|
||||
const result = await pool.query(`
|
||||
SELECT
|
||||
MAX(cliente_nome) as nome,
|
||||
cliente_fone as fone,
|
||||
SUM(quantidade * valor_unitario) as total_gasto,
|
||||
SUM(quantidade) as total_comprado
|
||||
FROM orders
|
||||
WHERE cliente_fone IS NOT NULL
|
||||
AND cliente_fone != ''
|
||||
GROUP BY cliente_fone
|
||||
ORDER BY total_gasto DESC
|
||||
LIMIT $1;
|
||||
`, [TOP_BUYERS_LIMIT]);
|
||||
|
||||
return result.rows;
|
||||
};
|
||||
|
||||
const claimPendingCampaignItems = async () => {
|
||||
const client = await pool.connect();
|
||||
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
|
||||
const result = await client.query(`
|
||||
UPDATE stock_campaign_queue
|
||||
SET status = 'processing',
|
||||
attempts = attempts + 1,
|
||||
updated_at = CURRENT_TIMESTAMP,
|
||||
last_error = NULL
|
||||
WHERE id IN (
|
||||
SELECT id
|
||||
FROM stock_campaign_queue
|
||||
WHERE status IN ('pending', 'failed')
|
||||
AND attempts < $1
|
||||
ORDER BY created_at ASC
|
||||
FOR UPDATE SKIP LOCKED
|
||||
)
|
||||
RETURNING *;
|
||||
`, [MAX_CAMPAIGN_ATTEMPTS]);
|
||||
|
||||
await client.query('COMMIT');
|
||||
return result.rows;
|
||||
} catch (error) {
|
||||
await client.query('ROLLBACK');
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
};
|
||||
|
||||
const updateCampaignItemsStatus = async (ids, status, errorMessage = null) => {
|
||||
if (!ids.length) return;
|
||||
|
||||
await pool.query(`
|
||||
UPDATE stock_campaign_queue
|
||||
SET status = $1,
|
||||
last_error = $2,
|
||||
updated_at = CURRENT_TIMESTAMP,
|
||||
sent_at = CASE WHEN $1 = 'sent' THEN CURRENT_TIMESTAMP ELSE sent_at END
|
||||
WHERE id = ANY($3::int[]);
|
||||
`, [status, errorMessage, ids]);
|
||||
};
|
||||
|
||||
const sendWhatsappCampaign = async (baseProductName, items, customers) => {
|
||||
const totalDelta = items.reduce((sum, item) => sum + Number(item.delta_estoque || 0), 0);
|
||||
|
||||
const response = await fetch(N8N_WHATSAPP_TRIGGER_URL, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
baseProduct: baseProductName,
|
||||
total_delta: totalDelta,
|
||||
sizes: items.map(item => ({
|
||||
id: item.produto_id,
|
||||
nome: item.nome,
|
||||
delta: item.delta_estoque,
|
||||
saldo: item.saldo
|
||||
})),
|
||||
customers
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`WhatsApp webhook returned status ${response.status}`);
|
||||
}
|
||||
};
|
||||
|
||||
const processPendingStockCampaigns = async () => {
|
||||
const rows = await claimPendingCampaignItems();
|
||||
const summary = {
|
||||
claimed: rows.length,
|
||||
sentGroups: 0,
|
||||
skippedGroups: 0,
|
||||
failedGroups: 0
|
||||
};
|
||||
|
||||
if (!rows.length) {
|
||||
return summary;
|
||||
}
|
||||
|
||||
const customers = await getTopBuyersAllTime();
|
||||
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;
|
||||
}, {});
|
||||
|
||||
for (const [baseProductName, items] of Object.entries(groups)) {
|
||||
const ids = items.map(item => item.id);
|
||||
|
||||
if (!customers.length) {
|
||||
await updateCampaignItemsStatus(ids, 'skipped', 'No customers with valid phone numbers found.');
|
||||
summary.skippedGroups += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
await sendWhatsappCampaign(baseProductName, items, customers);
|
||||
await updateCampaignItemsStatus(ids, 'sent');
|
||||
summary.sentGroups += 1;
|
||||
console.log(`[Campaign Queue] Sent ${baseProductName} campaign to ${customers.length} all-time top buyers.`);
|
||||
} catch (error) {
|
||||
await updateCampaignItemsStatus(ids, 'failed', error.message);
|
||||
summary.failedGroups += 1;
|
||||
console.error(`[Campaign Queue] Failed to send ${baseProductName} campaign:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
return summary;
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
enqueueStockCampaignItem,
|
||||
processPendingStockCampaigns
|
||||
};
|
||||
47
backend/services/ordersService.js
Normal file
47
backend/services/ordersService.js
Normal file
@@ -0,0 +1,47 @@
|
||||
const { pool } = require('../db');
|
||||
const { formatOrderRow, normalizeOrderPayload } = require('../mappers/orderMapper');
|
||||
|
||||
const listOrders = async () => {
|
||||
const result = await pool.query('SELECT * FROM orders ORDER BY id DESC');
|
||||
return result.rows.map(formatOrderRow);
|
||||
};
|
||||
|
||||
const upsertOrders = async (payload) => {
|
||||
const client = await pool.connect();
|
||||
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
|
||||
const insertQuery = `
|
||||
INSERT INTO orders (
|
||||
cliente_nome, data_pedido, valor_pedido,
|
||||
produto_id, produto_descricao, quantidade, valor_unitario, pedido_id, cliente_fone
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
ON CONFLICT (pedido_id, produto_id) DO UPDATE SET
|
||||
cliente_nome = EXCLUDED.cliente_nome,
|
||||
data_pedido = EXCLUDED.data_pedido,
|
||||
valor_pedido = EXCLUDED.valor_pedido,
|
||||
produto_descricao = EXCLUDED.produto_descricao,
|
||||
quantidade = EXCLUDED.quantidade,
|
||||
valor_unitario = EXCLUDED.valor_unitario,
|
||||
cliente_fone = EXCLUDED.cliente_fone,
|
||||
created_at = CURRENT_TIMESTAMP
|
||||
`;
|
||||
|
||||
for (const item of payload) {
|
||||
await client.query(insertQuery, normalizeOrderPayload(item));
|
||||
}
|
||||
|
||||
await client.query('COMMIT');
|
||||
} catch (error) {
|
||||
await client.query('ROLLBACK');
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
listOrders,
|
||||
upsertOrders
|
||||
};
|
||||
56
backend/services/stockService.js
Normal file
56
backend/services/stockService.js
Normal file
@@ -0,0 +1,56 @@
|
||||
const { pool } = require('../db');
|
||||
const { normalizeStockPayload } = require('../mappers/stockMapper');
|
||||
const { enqueueStockCampaignItem } = require('./campaignService');
|
||||
|
||||
const CAMPAIGN_DELTA_THRESHOLD = 100;
|
||||
|
||||
const listStock = async () => {
|
||||
const result = await pool.query('SELECT * FROM stock');
|
||||
return result.rows;
|
||||
};
|
||||
|
||||
const upsertStockItems = async (payload) => {
|
||||
const client = await pool.connect();
|
||||
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
|
||||
const insertQuery = `
|
||||
INSERT INTO stock (produto_id, nome, saldo, delta_estoque)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (produto_id) DO UPDATE SET
|
||||
nome = EXCLUDED.nome,
|
||||
saldo = EXCLUDED.saldo,
|
||||
delta_estoque = EXCLUDED.delta_estoque,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
`;
|
||||
|
||||
for (const rawItem of payload) {
|
||||
const item = normalizeStockPayload(rawItem);
|
||||
if (!item.produtoId) continue;
|
||||
|
||||
await client.query(insertQuery, [
|
||||
item.produtoId,
|
||||
item.nome,
|
||||
item.saldo,
|
||||
item.deltaEstoque
|
||||
]);
|
||||
|
||||
if (item.deltaEstoque >= CAMPAIGN_DELTA_THRESHOLD) {
|
||||
await enqueueStockCampaignItem(client, item);
|
||||
}
|
||||
}
|
||||
|
||||
await client.query('COMMIT');
|
||||
} catch (error) {
|
||||
await client.query('ROLLBACK');
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
listStock,
|
||||
upsertStockItems
|
||||
};
|
||||
@@ -76,7 +76,7 @@ const DateRangePicker: React.FC<DateRangePickerProps> = ({ dateRange, onChange,
|
||||
} else {
|
||||
ref.current.focus();
|
||||
}
|
||||
} catch (e) {
|
||||
} catch {
|
||||
ref.current.focus();
|
||||
}
|
||||
}
|
||||
@@ -183,4 +183,4 @@ const DateRangePicker: React.FC<DateRangePickerProps> = ({ dateRange, onChange,
|
||||
);
|
||||
};
|
||||
|
||||
export default DateRangePicker;
|
||||
export default DateRangePicker;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Outlet, Link, useLocation } from 'react-router-dom';
|
||||
import { LayoutDashboard, Users, BarChart3, ChevronLeft, ChevronRight, Package, Loader2, LogOut } from 'lucide-react';
|
||||
import type { DateRange, OrderData } from '../types';
|
||||
import type { DateRange, OrderData, StockData } from '../types';
|
||||
import { fetchData, fetchStock, logout } from '../dataService';
|
||||
|
||||
const Layout = () => {
|
||||
@@ -25,7 +25,7 @@ const Layout = () => {
|
||||
});
|
||||
|
||||
const [ordersData, setOrdersData] = useState<OrderData[]>([]);
|
||||
const [stockData, setStockData] = useState<any[]>([]);
|
||||
const [stockData, setStockData] = useState<StockData[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [refreshInterval, setRefreshInterval] = useState<number>(() => {
|
||||
const saved = localStorage.getItem('nexstar_refresh_interval');
|
||||
@@ -41,7 +41,9 @@ const Layout = () => {
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadData(true);
|
||||
// The dashboard has to fetch its initial server state after mount.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
void loadData(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { OrderData } from './types';
|
||||
import type { OrderData, StockData } from './types';
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL || '/api';
|
||||
|
||||
@@ -33,7 +33,7 @@ export const isAuthenticated = (): boolean => {
|
||||
return !!localStorage.getItem('auth_token');
|
||||
};
|
||||
|
||||
export const fetchStock = async (): Promise<any[]> => {
|
||||
export const fetchStock = async (): Promise<StockData[]> => {
|
||||
try {
|
||||
const token = localStorage.getItem('auth_token');
|
||||
const response = await fetch(`${API_URL}/stock`, {
|
||||
@@ -47,7 +47,7 @@ export const fetchStock = async (): Promise<any[]> => {
|
||||
}
|
||||
if (!response.ok) return [];
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
@@ -89,7 +89,7 @@ export const parseOrderDate = (dateStr: string): Date => {
|
||||
return isNaN(fallback.getTime()) ? new Date(0) : fallback;
|
||||
};
|
||||
|
||||
export const exportToCSV = (data: any[], filename: string) => {
|
||||
export const exportToCSV = (data: Record<string, unknown>[], filename: string) => {
|
||||
if (!data || !data.length) return;
|
||||
|
||||
const headers = Object.keys(data[0]);
|
||||
@@ -102,7 +102,7 @@ export const exportToCSV = (data: any[], filename: string) => {
|
||||
for (const row of data) {
|
||||
const values = headers.map(header => {
|
||||
const val = row[header];
|
||||
const escaped = ('' + val).replace(/"/g, '\\"');
|
||||
const escaped = String(val ?? '').replace(/"/g, '""');
|
||||
return `"${escaped}"`;
|
||||
});
|
||||
csvRows.push(values.join(','));
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import '@vitejs/plugin-react/preamble'
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { HashRouter } from 'react-router-dom'
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMemo, useState, useEffect } from 'react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Link, useOutletContext } from 'react-router-dom';
|
||||
import { Search, ChevronRight, Filter, ChevronLeft, Download } from 'lucide-react';
|
||||
import type { OrderData, DateRange } from '../types';
|
||||
@@ -20,11 +20,6 @@ const Clients = () => {
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [itemsPerPage, setItemsPerPage] = useState(10);
|
||||
|
||||
// Reset to first page when search, sort, or date changes
|
||||
useEffect(() => {
|
||||
setCurrentPage(1);
|
||||
}, [searchTerm, sortBy, dateRange]);
|
||||
|
||||
const clientsData = useMemo(() => {
|
||||
const orders = ordersData.filter(order => {
|
||||
const orderDate = parseOrderDate(order.Data_Pedido);
|
||||
@@ -101,14 +96,20 @@ const Clients = () => {
|
||||
<div className="flex flex-col sm:flex-row flex-wrap gap-3 items-center justify-start xl:justify-end">
|
||||
<DateRangePicker
|
||||
dateRange={dateRange}
|
||||
onChange={setDateRange}
|
||||
onChange={(range) => {
|
||||
setDateRange(range);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="relative">
|
||||
<Filter className="absolute left-3 top-1/2 transform -translate-y-1/2 text-zinc-400 dark:text-dark-muted w-4 h-4" />
|
||||
<select
|
||||
value={sortBy}
|
||||
onChange={(e) => setSortBy(e.target.value as SortOption)}
|
||||
onChange={(e) => {
|
||||
setSortBy(e.target.value as SortOption);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
className="appearance-none bg-dark-card border border-dark-border text-dark-text text-sm rounded-xl pl-9 pr-8 py-2.5 focus:outline-none focus:border-brand-primary transition-colors shadow-sm cursor-pointer"
|
||||
>
|
||||
<option value="recent">Mais Recentes</option>
|
||||
@@ -125,7 +126,10 @@ const Clients = () => {
|
||||
type="text"
|
||||
placeholder="Buscar cliente..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
onChange={(e) => {
|
||||
setSearchTerm(e.target.value);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
className="w-full sm:w-64 bg-dark-card border border-dark-border text-dark-text rounded-xl pl-10 pr-4 py-2.5 focus:outline-none focus:border-brand-primary hover:border-brand-primary transition-colors shadow-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -30,7 +30,23 @@ const formatCurrency = (value: number) => {
|
||||
return new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(value);
|
||||
};
|
||||
|
||||
const CustomTooltip = ({ active, payload, label, isCurrency }: any) => {
|
||||
type ChartTooltipPayload = {
|
||||
value: number;
|
||||
name?: string;
|
||||
color?: string;
|
||||
payload?: {
|
||||
fill?: string;
|
||||
};
|
||||
};
|
||||
|
||||
type CustomTooltipProps = {
|
||||
active?: boolean;
|
||||
payload?: ChartTooltipPayload[];
|
||||
label?: string;
|
||||
isCurrency?: boolean;
|
||||
};
|
||||
|
||||
const CustomTooltip = ({ active, payload, label, isCurrency }: CustomTooltipProps) => {
|
||||
if (active && payload && payload.length) {
|
||||
const color = payload[0].payload?.fill || payload[0].color || '#9ECAE1';
|
||||
const displayLabel = label || payload[0].name;
|
||||
|
||||
@@ -22,7 +22,7 @@ const Login = () => {
|
||||
} else {
|
||||
setError('E-mail ou senha incorretos.');
|
||||
}
|
||||
} catch (err) {
|
||||
} catch {
|
||||
setError('Erro ao conectar ao servidor.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
|
||||
@@ -6,7 +6,13 @@ import DateRangePicker from '../components/DateRangePicker';
|
||||
import type { OrderData, DateRange } from '../types';
|
||||
import { parseOrderDate } from '../dataService';
|
||||
|
||||
const CustomTooltip = ({ active, payload, label }: any) => {
|
||||
type CustomTooltipProps = {
|
||||
active?: boolean;
|
||||
payload?: Array<{ value: number }>;
|
||||
label?: string;
|
||||
};
|
||||
|
||||
const CustomTooltip = ({ active, payload, label }: CustomTooltipProps) => {
|
||||
if (active && payload && payload.length) {
|
||||
return (
|
||||
<div className="bg-[#141414] p-3 rounded-xl shadow-lg border-none">
|
||||
@@ -47,7 +53,8 @@ const ProductDetails = () => {
|
||||
orders.forEach(order => {
|
||||
const orderDate = parseOrderDate(order.Data_Pedido);
|
||||
|
||||
if (orderDate >= dateRange.start && orderDate <= dateRange.end) { const dateStr = order.Data_Pedido;
|
||||
if (orderDate >= dateRange.start && orderDate <= dateRange.end) {
|
||||
const dateStr = order.Data_Pedido;
|
||||
salesByDate[dateStr] = (salesByDate[dateStr] || 0) + order.Quantidade;
|
||||
sold += order.Quantidade;
|
||||
revenue += (order.Quantidade * order.Valor_Unitario);
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useMemo, useState, useEffect } from 'react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Link, useOutletContext } from 'react-router-dom';
|
||||
import { Search, Package, TrendingUp, ChevronLeft, ChevronRight, Download } from 'lucide-react';
|
||||
import DateRangePicker from '../components/DateRangePicker';
|
||||
import type { OrderData, DateRange } from '../types';
|
||||
import type { OrderData, DateRange, StockData } from '../types';
|
||||
import { parseOrderDate, exportToCSV } from '../dataService';
|
||||
|
||||
const Products = () => {
|
||||
@@ -10,7 +10,7 @@ const Products = () => {
|
||||
dateRange: DateRange,
|
||||
setDateRange: (range: DateRange) => void,
|
||||
ordersData: OrderData[],
|
||||
stockData: any[],
|
||||
stockData: StockData[],
|
||||
refreshInterval: number,
|
||||
setRefreshInterval: (interval: number) => void,
|
||||
loadData: (showLoading?: boolean) => void
|
||||
@@ -21,11 +21,6 @@ const Products = () => {
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [itemsPerPage, setItemsPerPage] = useState(10);
|
||||
|
||||
// Reset to first page when search or date range changes
|
||||
useEffect(() => {
|
||||
setCurrentPage(1);
|
||||
}, [searchTerm, dateRange]);
|
||||
|
||||
const productsData = useMemo(() => {
|
||||
const orders = ordersData;
|
||||
const productMap: Record<string, { id: string, name: string, totalSold: number, revenue: number, lastPrice: number, stock: number }> = {};
|
||||
@@ -75,7 +70,7 @@ const Products = () => {
|
||||
}
|
||||
|
||||
return result.sort((a, b) => b.totalSold - a.totalSold);
|
||||
}, [dateRange, searchTerm, ordersData]);
|
||||
}, [dateRange, searchTerm, ordersData, stockData]);
|
||||
|
||||
// Pagination logic
|
||||
const totalPages = Math.ceil(productsData.length / itemsPerPage);
|
||||
@@ -97,7 +92,10 @@ const Products = () => {
|
||||
<div className="flex flex-col sm:flex-row gap-3">
|
||||
<DateRangePicker
|
||||
dateRange={dateRange}
|
||||
onChange={setDateRange}
|
||||
onChange={(range) => {
|
||||
setDateRange(range);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="relative">
|
||||
@@ -106,7 +104,10 @@ const Products = () => {
|
||||
type="text"
|
||||
placeholder="Buscar por nome ou ID..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
onChange={(e) => {
|
||||
setSearchTerm(e.target.value);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
className="w-full md:w-64 bg-dark-card border border-dark-border text-dark-text rounded-xl pl-10 pr-4 py-2.5 focus:outline-none focus:border-brand-primary hover:border-brand-primary transition-colors shadow-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -11,6 +11,14 @@ export interface OrderData {
|
||||
Fone_Cliente?: string;
|
||||
}
|
||||
|
||||
export interface StockData {
|
||||
produto_id: string;
|
||||
nome: string;
|
||||
saldo: number;
|
||||
delta_estoque: number;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
export interface DateRange {
|
||||
start: Date;
|
||||
end: Date;
|
||||
|
||||
@@ -3,8 +3,27 @@ import react from '@vitejs/plugin-react'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss()],
|
||||
export default defineConfig(({ command }) => ({
|
||||
plugins: [
|
||||
command === 'serve' && {
|
||||
name: 'react-refresh-preamble-fix',
|
||||
transformIndexHtml() {
|
||||
return [{
|
||||
tag: 'script',
|
||||
attrs: { type: 'module' },
|
||||
children: `
|
||||
import RefreshRuntime from "/@react-refresh";
|
||||
RefreshRuntime.injectIntoGlobalHook(window);
|
||||
window.$RefreshReg$ = () => {};
|
||||
window.$RefreshSig$ = () => (type) => type;
|
||||
window.__vite_plugin_react_preamble_installed__ = true;
|
||||
`
|
||||
}]
|
||||
}
|
||||
},
|
||||
react(),
|
||||
tailwindcss()
|
||||
],
|
||||
server: {
|
||||
port: 3001,
|
||||
proxy: {
|
||||
@@ -23,4 +42,4 @@ export default defineConfig({
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
Reference in New Issue
Block a user