Compare commits

...

24 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
Cauê Faleiros
e2d0e94080 add campaign observability page
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 43s
2026-05-28 11:07:46 -03:00
Cauê Faleiros
6c0a78675c chore: persist local docker stack
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m39s
2026-05-28 10:49:46 -03:00
Cauê Faleiros
440c8cee1f combine ready products in campaign payload
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 46s
2026-05-27 16:31:26 -03:00
Cauê Faleiros
62a0bcfbc9 docs: add project context and remove boilerplate
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 44s
2026-05-27 16:18:10 -03:00
Cauê Faleiros
5e0bb1d83a accumulate stock deltas before campaigns
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 52s
2026-05-27 16:14:09 -03:00
Cauê Faleiros
72ded82ec7 extract frontend analytics helpers
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m12s
2026-05-27 15:58:12 -03:00
Cauê Faleiros
8c2590c56a refactor backend and persist stock campaign queue
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m32s
2026-05-27 15:00:23 -03:00
Cauê Faleiros
6ba8219596 fix: map N8N_WHATSAPP_TRIGGER_URL to backend container in docker-compose
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m7s
2026-05-26 15:57:53 -03:00
Cauê Faleiros
69f99b97c5 feat: add observability logs to stock waiting room for easier debugging in Portainer
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m8s
2026-05-26 15:11:00 -03:00
Cauê Faleiros
1f8baabf69 style: remove dynamic color coding from stock column to match table styling
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m26s
2026-05-25 11:35:15 -03:00
Cauê Faleiros
c47a64d831 feat: display available stock balance on products page based on n8n inventory updates 2026-05-25 11:26:00 -03:00
Cauê Faleiros
4ce1e9aedb style: remove WhatsApp link from client phone number
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m24s
2026-05-22 13:57:13 -03:00
Cauê Faleiros
fc8a5e47a0 fix: send Fone_Cliente from backend to frontend API response 2026-05-22 13:40:31 -03:00
Cauê Faleiros
174bb4841e feat: display customer phone number on client details page with WhatsApp quick link 2026-05-22 12:10:10 -03:00
Cauê Faleiros
9e52b2e44f feat: safely implement database idempotency, fallback IDs, and WhatsApp marketing export
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m30s
2026-05-22 10:54:54 -03:00
53 changed files with 2649 additions and 982 deletions

View File

@@ -13,6 +13,7 @@ POSTGRES_DB=graphdb
# --- Application Security --- # --- Application Security ---
# The API key used by n8n to authenticate with the backend # The API key used by n8n to authenticate with the backend
API_KEY=nexstar_secret_key_123 API_KEY=nexstar_secret_key_123
N8N_WHATSAPP_TRIGGER_URL=https://n8n.example.com/webhook/whatsapp-stock
# --- Dashboard Login Credentials --- # --- Dashboard Login Credentials ---
ADMIN_EMAIL=admin@admin.com ADMIN_EMAIL=admin@admin.com

99
CONTEXT.md Normal file
View File

@@ -0,0 +1,99 @@
# Context
## 1. Project Overview
This project (often referred to as "Nexstar Graphs" or simply "Graphs") is a real-time sales and inventory dashboard. Its primary purpose is to ingest live webhook payloads from an external ERP (Tiny ERP) via n8n, securely store that data, and provide a visually rich, responsive dashboard for business analytics. It tracks total sales, product performance, customer behavior, and live inventory levels, while also providing tools for WhatsApp marketing campaigns.
## 2. Tech Stack & Tooling
**Frontend:**
* **Library:** React 19.2.5
* **Language:** TypeScript 6.0.2
* **Build Tool:** Vite 8.0.10
* **Styling:** Tailwind CSS 4.2.4
* **Icons:** Lucide React 1.14.0
* **Charts:** Recharts 3.8.1
* **Routing:** React Router DOM 7.14.2
**Backend:**
* **Environment:** Node.js
* **Framework:** Express 5.2.1
* **Database:** PostgreSQL (via `pg` 8.20.0)
* **Authentication:** JWT (`jsonwebtoken` 9.0.3)
* **CORS & Middleware:** `cors`, `body-parser`
**Infrastructure & CI/CD:**
* **Containerization:** Docker & Docker Compose
* **Proxy:** Nginx
* **CI/CD:** Gitea Actions (deploy.yml)
* **Automation:** n8n (External trigger source)
## 3. Architecture & Design Decisions
* **Decoupled Client-Server:** The frontend is a statically built SPA served by Nginx, communicating with an isolated Node.js API.
* **Idempotent Database Operations:** Due to potential retry/spam from n8n webhooks, database insertions strictly use `INSERT ... ON CONFLICT DO UPDATE SET` (UPSERTs). The backend dynamically generates fallback IDs using composite keys (`Name_Date_Value`) to prevent historical data squashing when explicit `ID_Pedido` fields are missing.
* **"Waiting Room" Debounce Pattern:** To prevent webhook spam during massive inventory updates, the `/api/stock` route intercepts payloads with large deltas (`>= 100`). It parks them in an in-memory dictionary grouped by Base Product Name, accumulates the numbers over a 30-minute setTimeout, and then executes a single aggregated webhook to N8N targeting the Top 100 buyers.
* **Smart Polling vs. SSE:** Real-time UI updates are handled via client-side polling (`setInterval`) rather than Server-Sent Events (SSE) to bypass persistent connection drops caused by production reverse proxies.
* **Client-Side Analytics:** The backend returns raw data arrays; sorting, mapping, deduplication (e.g., grouping unique orders by time), and chart metric generation are processed dynamically in the React `useMemo` hooks to reduce server load.
## 4. Directory Structure
```text
/
├── .gitea/workflows/ # CI/CD pipeline definitions
├── backend/ # Node.js Express API
│ ├── Dockerfile # Backend container definition
│ ├── index.js # Core API logic, DB initialization, and Webhook handlers
│ └── package.json
├── public/ # Static assets (Favicons)
├── src/ # React Frontend Application
│ ├── components/ # Reusable UI elements (Layout, DateRangePicker)
│ ├── pages/ # Route-level views (Dashboard, Products, Clients)
│ ├── dataService.ts # Centralized API fetch logic and JWT handling
│ ├── types.ts # Shared TypeScript interfaces
│ └── main.tsx # React entry point
├── docker-compose.yml # Local orchestration and environment variable mapping
├── nginx.conf # Production web server routing
└── vite.config.ts # Frontend build configuration
```
## 5. Core Business Rules & Domain Entities
* **Order Entity (`orders` table):** Tracks `cliente_nome`, `data_pedido`, `valor_pedido`, `produto_id`, `quantidade`, `valor_unitario`, `pedido_id`, and `cliente_fone`.
* **Stock Entity (`stock` table):** Tracks `produto_id`, `nome`, `saldo` (absolute current inventory), and `delta_estoque`. The database treats the ERP's `saldo` as the Absolute Truth (overwriting existing values rather than performing math) to prevent desynchronization.
* **WhatsApp Marketing Integration:** The system actively extracts phone numbers from incoming n8n payloads (checking `Fone_Cliente`, `fone`, or `celular`). Numbers are exposed in the UI for direct "Click-to-Chat" links and exported to CSV files for bulk marketing.
* **Filter Persistence:** User preferences for Date Ranges, Sort options, and Auto-Refresh intervals are rigidly persisted to `localStorage` to survive page reloads. The "Hoje" (Today) date preset explicitly extends to `23:59:59.999` to ensure incoming real-time webhooks remain visible on the current day's graph.
## 6. CI/CD & Deployment
* **Gitea Actions:** A workflow located in `.gitea/workflows/deploy.yml` triggers on pushes to the `main` branch.
* **Docker Registry:** The pipeline builds the `frontend` and `backend` Docker images and pushes them directly to `gitea.blyzer.com.br/blyzer/`.
* **Production Deployment:** Updates are deployed manually via Portainer by pulling the `latest` image tags from the Gitea registry and redeploying the stack.
* **Environment Variables:** Security secrets (`API_KEY`, `JWT_SECRET`, `POSTGRES_PASSWORD`, `N8N_WHATSAPP_TRIGGER_URL`) are injected via the Portainer stack configuration and passed into containers via `docker-compose.yml`.
## 7. Environment Setup & Scripts
**Running Locally:**
1. Start the database:
```bash
docker compose up -d db
```
2. Start the Backend (from `/backend`):
```bash
npm install
npm start
```
3. Start the Frontend (from project root):
```bash
npm install
npm run dev # For HMR development
npm run preview # For production build testing
```
**Building the Frontend:**
```bash
npm run build
```
## 8. Coding Standards & AI Directives
* **Strict Type Safety:** Use explicit TypeScript interfaces (defined in `types.ts`). Avoid `any` where possible. Do not bypass type checks with `// @ts-ignore`.
* **Idiomatic React:** Use functional components and hooks (`useState`, `useEffect`, `useMemo`). Complex data transformations (like merging arrays into chart-ready datasets) MUST be wrapped in `useMemo` to prevent unnecessary re-renders.
* **Tailwind Architecture:** All styling must be handled via Tailwind CSS utility classes. Avoid custom CSS files unless defining global font families or root variables in `index.css`.
* **Robust Data Handling:** Always implement graceful fallbacks for missing data. Never assume an API payload will contain all keys. (e.g., `item.id || item.ID_Pedido || ''`).
* **Database Migrations:** There is no ORM (like Prisma or Sequelize). Table schemas and indexes are managed via raw SQL statements inside the `initDB()` function in `backend/index.js` using `IF NOT EXISTS` clauses for safe startup execution.
* **API Security:** All backend modifications exposing or altering data MUST use the `verifyToken` middleware for frontend requests or `authenticateAPIKey` for external n8n webhooks.

169
README.md
View File

@@ -1,73 +1,116 @@
# React + TypeScript + Vite # Nexstar Graphs
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. Real-time sales and stock dashboard for Nexstar. The app receives Tiny ERP data through n8n webhooks, stores it in PostgreSQL, and renders sales, products, clients, stock, and WhatsApp campaign data in a React dashboard.
Currently, two official plugins are available: ## Stack
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs) - Frontend: React, TypeScript, Vite, Tailwind CSS, Recharts
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) - Backend: Node.js, Express, PostgreSQL, JWT, API-key webhook auth
- Runtime: Docker Compose, Nginx, n8n
## React Compiler ## Main Flows
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation). ### Sales Ingestion
## Expanding the ESLint configuration ```text
n8n -> POST /api/data -> PostgreSQL orders -> dashboard
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
``` ```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules: The endpoint accepts a single order item or an array. Requests must include:
```js ```text
// eslint.config.js x-api-key: <API_KEY>
import reactX from 'eslint-plugin-react-x' Content-Type: application/json
import reactDom from 'eslint-plugin-react-dom' ```
export default defineConfig([ ### Stock Ingestion
globalIgnores(['dist']),
{ ```text
files: ['**/*.{ts,tsx}'], n8n -> POST /api/stock -> PostgreSQL stock + campaign queue
extends: [ ```
// Other configs...
// Enable lint rules for React Positive stock deltas are queued for WhatsApp campaigns. The scheduled processor groups pending queue rows by base product name and sends a campaign only when the accumulated pending delta reaches at least `100`.
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM ### Scheduled WhatsApp Campaigns
reactDom.configs.recommended,
], ```text
languageOptions: { n8n schedule at 12:00/18:00 BRT
parserOptions: { -> POST /api/internal/process-stock-campaigns
project: ['./tsconfig.node.json', './tsconfig.app.json'], -> backend calls N8N_WHATSAPP_TRIGGER_URL
tsconfigRootDir: import.meta.dirname, -> n8n WhatsApp workflow sends templates
}, ```
// other options...
}, The scheduled endpoint is API-key protected and returns a summary:
},
]) ```json
{
"claimed": 0,
"sentGroups": 0,
"skippedGroups": 0,
"failedGroups": 0,
"pendingBelowThresholdGroups": 0
}
```
## Local Development
Start PostgreSQL:
```bash
docker compose up -d db
```
Start the backend:
```bash
cd backend
npm install
npm start
```
Start the frontend:
```bash
npm install
npm run dev
```
Default local URLs:
```text
Frontend: http://127.0.0.1:3001
Backend: http://127.0.0.1:3004
```
Vite may choose a different frontend port if `3001` is already in use.
## Environment
Copy `.env.example` and configure production secrets in the runtime environment:
```text
POSTGRES_USER
POSTGRES_PASSWORD
POSTGRES_DB
API_KEY
N8N_WHATSAPP_TRIGGER_URL
ADMIN_EMAIL
ADMIN_PASSWORD
JWT_SECRET
```
## Validation
```bash
npm run lint
npm run build
```
For backend syntax checks:
```bash
cd backend
node --check index.js
node --check services/campaignService.js
node --check services/stockService.js
``` ```

40
backend/auth.js Normal file
View 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
View 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'
};

136
backend/db.js Normal file
View File

@@ -0,0 +1,136 @@
const { Pool } = require('pg');
const { DATABASE_URL } = require('./config');
const pool = new Pool({
connectionString: DATABASE_URL
});
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 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);
});
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 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,
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 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) {
console.error('Failed to initialize database:', err);
throw err;
}
};
module.exports = {
pool,
initDB
};

View File

@@ -1,174 +1,19 @@
const express = require('express'); const { createApp } = require('./server');
const cors = require('cors'); const { initDB } = require('./db');
const bodyParser = require('body-parser'); const { PORT } = require('./config');
const { Pool } = require('pg');
const jwt = require('jsonwebtoken');
require('dotenv').config();
const app = express(); const start = async () => {
const PORT = process.env.PORT || 3004; await initDB();
const API_KEY = process.env.API_KEY || "nexstar_secret_key_123";
// Admin Credentials const app = createApp();
const ADMIN_EMAIL = process.env.ADMIN_EMAIL || 'admin@admin.com'; app.listen(PORT, '0.0.0.0', () => {
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || 'admin123'; console.log(`Nexstar Backend running at http://localhost:${PORT}`);
const JWT_SECRET = process.env.JWT_SECRET || 'super_secret_jwt_key_123'; 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`);
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(`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.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();
}); });
}; };
// Login Endpoint start().catch((error) => {
app.post('/api/login', (req, res) => { console.error('Failed to start backend:', error);
const { email, password } = req.body; process.exit(1);
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' });
}
});
// 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
});
// 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' });
}
});
// 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
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
`;
for (const item of payload) {
// Handle potential missing fields gracefully
const orderId = item.id || item.ID_Pedido || (item.json && item.json.body && item.json.body.id) || '';
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)
];
await client.query(insertQuery, values);
}
await client.query('COMMIT');
} catch (error) {
await client.query('ROLLBACK');
console.error("Database 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`);
});;
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`);
}); });

View File

@@ -0,0 +1,62 @@
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 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',
orderDate,
normalizeOrderDate(orderDate),
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,
normalizeOrderDate,
normalizeOrderPayload
};

View File

@@ -0,0 +1,31 @@
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 || '';
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
};

View File

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

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

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

View File

@@ -0,0 +1,48 @@
const express = require('express');
const { verifyToken } = require('../auth');
const {
getCampaignPreview,
getCampaignQueueSummary,
processPendingStockCampaigns,
retryCampaignItems
} = require('../services/campaignService');
const router = express.Router();
router.get('/campaigns', verifyToken, async (req, res) => {
try {
res.json(await getCampaignQueueSummary());
} catch (error) {
console.error('Error fetching campaigns:', error);
res.status(500).json({ error: 'Internal Server Error' });
}
});
router.get('/campaigns/preview', verifyToken, async (req, res) => {
try {
res.json(await getCampaignPreview());
} catch (error) {
console.error('Error fetching campaign preview:', error);
res.status(500).json({ error: 'Internal Server Error' });
}
});
router.post('/campaigns/process', verifyToken, async (req, res) => {
try {
res.json(await processPendingStockCampaigns());
} catch (error) {
console.error('Error processing campaigns:', error);
res.status(500).json({ error: 'Internal Server Error' });
}
});
router.post('/campaigns/retry', verifyToken, async (req, res) => {
try {
res.json(await retryCampaignItems(req.body || {}));
} catch (error) {
console.error('Error retrying campaigns:', error);
res.status(500).json({ error: 'Internal Server Error' });
}
});
module.exports = router;

View 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;

View 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;

View 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;

29
backend/server.js Normal file
View File

@@ -0,0 +1,29 @@
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 campaignRoutes = require('./routes/campaignRoutes');
const internalRoutes = require('./routes/internalRoutes');
const analyticsRoutes = require('./routes/analyticsRoutes');
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', campaignRoutes);
app.use('/api', analyticsRoutes);
app.use('/api/internal', internalRoutes);
return app;
};
module.exports = {
createApp
};

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

@@ -0,0 +1,265 @@
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;
const CAMPAIGN_DELTA_THRESHOLD = 100;
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 claimReadyCampaignItems = async () => {
const client = await pool.connect();
try {
await client.query('BEGIN');
const result = await client.query(`
WITH ready_groups AS (
SELECT base_product_name
FROM stock_campaign_queue
WHERE status IN ('pending', 'failed')
AND attempts < $1
GROUP BY base_product_name
HAVING SUM(delta_estoque) >= $2
),
ready_items AS (
SELECT queue.id
FROM stock_campaign_queue queue
JOIN ready_groups ON ready_groups.base_product_name = queue.base_product_name
WHERE queue.status IN ('pending', 'failed')
AND queue.attempts < $1
ORDER BY queue.created_at ASC
FOR UPDATE OF queue SKIP LOCKED
)
UPDATE stock_campaign_queue
SET status = 'processing',
attempts = attempts + 1,
updated_at = CURRENT_TIMESTAMP,
last_error = NULL
WHERE id IN (SELECT id FROM ready_items)
RETURNING *;
`, [MAX_CAMPAIGN_ATTEMPTS, CAMPAIGN_DELTA_THRESHOLD]);
await client.query('COMMIT');
return result.rows;
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
};
const countPendingBelowThresholdGroups = async () => {
const result = await pool.query(`
SELECT COUNT(*)::int as count
FROM (
SELECT base_product_name
FROM stock_campaign_queue
WHERE status IN ('pending', 'failed')
AND attempts < $1
GROUP BY base_product_name
HAVING SUM(delta_estoque) < $2
) below_threshold_groups;
`, [MAX_CAMPAIGN_ATTEMPTS, CAMPAIGN_DELTA_THRESHOLD]);
return result.rows[0]?.count || 0;
};
const getCampaignQueueRows = async () => {
const result = await pool.query(`
SELECT *
FROM stock_campaign_queue
ORDER BY created_at DESC, id DESC
LIMIT 500;
`);
return result.rows;
};
const getCampaignQueueSummary = async () => {
const rows = await getCampaignQueueRows();
return {
threshold: CAMPAIGN_DELTA_THRESHOLD,
maxAttempts: MAX_CAMPAIGN_ATTEMPTS,
groups: groupCampaignRows(rows),
rows
};
};
const getCampaignPreview = async () => {
const result = await pool.query(`
SELECT *
FROM stock_campaign_queue
WHERE status IN ('pending', 'failed')
AND attempts < $1
ORDER BY created_at ASC, id ASC;
`, [MAX_CAMPAIGN_ATTEMPTS]);
const groups = groupCampaignRowsByBaseProduct(result.rows);
const readyGroups = {};
const belowThresholdGroups = {};
Object.entries(groups).forEach(([baseProductName, items]) => {
const totalDelta = items.reduce((sum, item) => sum + Number(item.delta_estoque || 0), 0);
if (totalDelta >= CAMPAIGN_DELTA_THRESHOLD) {
readyGroups[baseProductName] = items;
} else {
belowThresholdGroups[baseProductName] = items;
}
});
const readyProducts = mapCampaignProducts(readyGroups).map(({ itemIds, ...product }) => product);
const belowThresholdProducts = mapCampaignProducts(belowThresholdGroups).map(({ itemIds, ...product }) => product);
const customers = await getTopBuyersAllTime();
return {
threshold: CAMPAIGN_DELTA_THRESHOLD,
readyProducts,
belowThresholdProducts,
productsText: readyProducts.length ? formatProductList(readyProducts.map(product => product.baseProduct)) : '',
customerCount: customers.length,
customersPreview: customers.slice(0, 10)
};
};
const retryCampaignItems = async ({ ids, baseProductName } = {}) => {
const params = [];
const filters = [`status IN ('failed', 'skipped')`];
if (Array.isArray(ids) && ids.length) {
params.push(ids.map(Number));
filters.push(`id = ANY($${params.length}::int[])`);
}
if (baseProductName) {
params.push(baseProductName);
filters.push(`base_product_name = $${params.length}`);
}
const result = await pool.query(`
UPDATE stock_campaign_queue
SET status = 'pending',
attempts = 0,
last_error = NULL,
sent_at = NULL,
updated_at = CURRENT_TIMESTAMP
WHERE ${filters.join(' AND ')}
RETURNING *;
`, params);
return {
retried: result.rowCount,
rows: result.rows
};
};
const updateCampaignItemsStatus = async (ids, status, errorMessage = null) => {
if (!ids.length) return;
await pool.query(`
UPDATE stock_campaign_queue
SET status = $1::varchar,
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 (products, customers) => {
const response = await fetch(N8N_WHATSAPP_TRIGGER_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(buildWhatsappCampaignPayload(products, customers))
});
if (!response.ok) {
throw new Error(`WhatsApp webhook returned status ${response.status}`);
}
};
const processPendingStockCampaigns = async () => {
const rows = await claimReadyCampaignItems();
const summary = {
claimed: rows.length,
sentGroups: 0,
skippedGroups: 0,
failedGroups: 0,
pendingBelowThresholdGroups: await countPendingBelowThresholdGroups()
};
if (!rows.length) {
return summary;
}
const groups = groupCampaignRowsByBaseProduct(rows);
const products = mapCampaignProducts(groups);
const ids = products.flatMap(product => product.itemIds);
const customers = await getTopBuyersAllTime();
if (!customers.length) {
await updateCampaignItemsStatus(ids, 'skipped', 'No customers with valid phone numbers found.');
summary.skippedGroups = products.length;
return summary;
}
try {
await sendWhatsappCampaign(products, customers);
await updateCampaignItemsStatus(ids, 'sent');
summary.sentGroups = products.length;
console.log(`[Campaign Queue] Sent one campaign with ${products.length} products to ${customers.length} all-time top buyers.`);
} catch (error) {
await updateCampaignItemsStatus(ids, 'failed', error.message);
summary.failedGroups = products.length;
console.error('[Campaign Queue] Failed to send product list campaign:', error);
}
return summary;
};
module.exports = {
enqueueStockCampaignItem,
getCampaignPreview,
getCampaignQueueSummary,
retryCampaignItems,
processPendingStockCampaigns
};

View File

@@ -0,0 +1,48 @@
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, 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, $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,
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
};

View File

@@ -0,0 +1,56 @@
const { pool } = require('../db');
const { normalizeStockPayload } = require('../mappers/stockMapper');
const { enqueueStockCampaignItem } = require('./campaignService');
const POSITIVE_STOCK_DELTA_THRESHOLD = 1;
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 >= POSITIVE_STOCK_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
};

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,5 +1,3 @@
version: '3.8'
services: services:
db: db:
image: postgres:15-alpine image: postgres:15-alpine
@@ -28,6 +26,7 @@ services:
- ADMIN_EMAIL=${ADMIN_EMAIL:-admin@admin.com} - ADMIN_EMAIL=${ADMIN_EMAIL:-admin@admin.com}
- ADMIN_PASSWORD=${ADMIN_PASSWORD:-admin123} - ADMIN_PASSWORD=${ADMIN_PASSWORD:-admin123}
- JWT_SECRET=${JWT_SECRET:-super_secret_jwt_key_123} - JWT_SECRET=${JWT_SECRET:-super_secret_jwt_key_123}
- N8N_WHATSAPP_TRIGGER_URL=${N8N_WHATSAPP_TRIGGER_URL:-http://localhost:5678/webhook/whatsapp}
depends_on: depends_on:
- db - db
restart: unless-stopped restart: unless-stopped

View File

@@ -1,33 +0,0 @@
const products = [];
// Group 1: High Quantity, Low Price (Top 10 in Bar Chart, won't show in Pie Chart)
for (let i = 0; i < 10; i++) {
products.push({
Nome_Cliente: "Fake Client A" + i,
Data_Pedido: "06-05-2026",
Valor_Pedido: 100,
ID_Produto: "QA" + i,
Descricao_Produto: "Produto Muito Vendido " + i,
Quantidade: 1000 + i, // High sales
Valor_Unitario: 0.10 // Low revenue
});
}
// Group 2: Low Quantity, High Price (Top 10 in Pie Chart, won't show in Bar Chart)
for (let i = 0; i < 10; i++) {
products.push({
Nome_Cliente: "Fake Client B" + i,
Data_Pedido: "06-05-2026",
Valor_Pedido: 10000,
ID_Produto: "QB" + i,
Descricao_Produto: "Produto Muito Caro " + i,
Quantidade: 1, // Low sales
Valor_Unitario: 10000 + i // High revenue
});
}
fetch('http://localhost:3004/api/data', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'x-api-key': 'nexstar_secret_key_123' },
body: JSON.stringify(products)
}).then(res => console.log("Status:", res.status)).catch(console.error);

View File

@@ -1,42 +0,0 @@
// Clear previous data for a clean slate
const { Pool } = require('pg');
const pool = new Pool({ connectionString: 'postgres://graphuser:graphpassword@localhost:5432/graphdb' });
async function run() {
await pool.query('TRUNCATE TABLE orders RESTART IDENTITY;');
// Group 1: High Quantity, Low Price (Top 10 in Bar Chart, won't show in Pie Chart)
const group1 = Array.from({length: 10}, (_, i) => ({
Nome_Cliente: "Fake Client A" + i,
Data_Pedido: "06-05-2026",
Valor_Pedido: 100,
ID_Produto: "QA" + i,
Descricao_Produto: "Produto Muito Vendido " + i,
Quantidade: 1000 + i, // High sales
Valor_Unitario: 0.10 // Low revenue
}));
// Group 2: Low Quantity, High Price (Top 10 in Pie Chart, won't show in Bar Chart)
const group2 = Array.from({length: 10}, (_, i) => ({
Nome_Cliente: "Fake Client B" + i,
Data_Pedido: "06-05-2026",
Valor_Pedido: 10000,
ID_Produto: "QB" + i,
Descricao_Produto: "Produto Muito Caro " + i,
Quantidade: 1, // Low sales
Valor_Unitario: 10000 + i // High revenue
}));
const products = [...group1, ...group2];
const res = await fetch('http://localhost:3004/api/data', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'x-api-key': 'nexstar_secret_key_123' },
body: JSON.stringify(products)
});
console.log(res.status);
process.exit(0);
}
run();

View File

@@ -1,184 +0,0 @@
.counter {
font-size: 16px;
padding: 5px 10px;
border-radius: 5px;
color: var(--accent);
background: var(--accent-bg);
border: 2px solid transparent;
transition: border-color 0.3s;
margin-bottom: 24px;
&:hover {
border-color: var(--accent-border);
}
&:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
}
.hero {
position: relative;
.base,
.framework,
.vite {
inset-inline: 0;
margin: 0 auto;
}
.base {
width: 170px;
position: relative;
z-index: 0;
}
.framework,
.vite {
position: absolute;
}
.framework {
z-index: 1;
top: 34px;
height: 28px;
transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
scale(1.4);
}
.vite {
z-index: 0;
top: 107px;
height: 26px;
width: auto;
transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
scale(0.8);
}
}
#center {
display: flex;
flex-direction: column;
gap: 25px;
place-content: center;
place-items: center;
flex-grow: 1;
@media (max-width: 1024px) {
padding: 32px 20px 24px;
gap: 18px;
}
}
#next-steps {
display: flex;
border-top: 1px solid var(--border);
text-align: left;
& > div {
flex: 1 1 0;
padding: 32px;
@media (max-width: 1024px) {
padding: 24px 20px;
}
}
.icon {
margin-bottom: 16px;
width: 22px;
height: 22px;
}
@media (max-width: 1024px) {
flex-direction: column;
text-align: center;
}
}
#docs {
border-right: 1px solid var(--border);
@media (max-width: 1024px) {
border-right: none;
border-bottom: 1px solid var(--border);
}
}
#next-steps ul {
list-style: none;
padding: 0;
display: flex;
gap: 8px;
margin: 32px 0 0;
.logo {
height: 18px;
}
a {
color: var(--text-h);
font-size: 16px;
border-radius: 6px;
background: var(--social-bg);
display: flex;
padding: 6px 12px;
align-items: center;
gap: 8px;
text-decoration: none;
transition: box-shadow 0.3s;
&:hover {
box-shadow: var(--shadow);
}
.button-icon {
height: 18px;
width: 18px;
}
}
@media (max-width: 1024px) {
margin-top: 20px;
flex-wrap: wrap;
justify-content: center;
li {
flex: 1 1 calc(50% - 8px);
}
a {
width: 100%;
justify-content: center;
box-sizing: border-box;
}
}
}
#spacer {
height: 88px;
border-top: 1px solid var(--border);
@media (max-width: 1024px) {
height: 48px;
}
}
.ticks {
position: relative;
width: 100%;
&::before,
&::after {
content: '';
position: absolute;
top: -4.5px;
border: 5px solid transparent;
}
&::before {
left: 0;
border-left-color: var(--border);
}
&::after {
right: 0;
border-right-color: var(--border);
}
}

View File

@@ -1,14 +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 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()) {
@@ -17,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 />} />
@@ -28,8 +38,10 @@ function App() {
<Route path="products/:id" element={<ProductDetails />} /> <Route path="products/:id" element={<ProductDetails />} />
<Route path="clients" element={<Clients />} /> <Route path="clients" element={<Clients />} />
<Route path="clients/:name" element={<ClientDetails />} /> <Route path="clients/:name" element={<ClientDetails />} />
<Route path="campaigns" element={<Campaigns />} />
</Route> </Route>
</Routes> </Routes>
</Suspense>
); );
} }

122
src/analytics/clients.ts Normal file
View File

@@ -0,0 +1,122 @@
import type { DateRange, OrderData } from '../types';
import { parseOrderDate } from '../dataService';
import { filterOrdersByDateRange, getClientDisplayName, getOrderItemRevenue } from './orders';
export type ClientSortOption = 'recent' | 'spent_desc' | 'spent_asc' | 'items_desc' | 'items_asc';
export interface ClientSummary {
name: string;
phone: string;
totalSpent: number;
totalItems: number;
orderCount: number;
lastPurchase: number;
}
export interface GroupedClientOrder {
date: string;
orderId: string;
orderTotal: number;
items: OrderData[];
}
export interface ClientDetailsMetrics {
groupedOrders: GroupedClientOrder[];
totalSpent: number;
totalItems: number;
clientPhone: string;
}
export const buildClientsSummary = (
ordersData: OrderData[],
dateRange: DateRange,
searchTerm: string,
sortBy: ClientSortOption
): ClientSummary[] => {
const orders = filterOrdersByDateRange(ordersData, dateRange);
const clientMap: Record<string, {
totalSpent: number;
totalItems: number;
uniqueOrders: Set<string>;
lastPurchase: number;
phone: string;
}> = {};
orders.forEach(order => {
const clientName = getClientDisplayName(order);
if (!clientMap[clientName]) {
clientMap[clientName] = { totalSpent: 0, totalItems: 0, uniqueOrders: new Set(), lastPurchase: 0, phone: '' };
}
if (order.Fone_Cliente) {
clientMap[clientName].phone = order.Fone_Cliente;
}
clientMap[clientName].totalSpent += getOrderItemRevenue(order);
clientMap[clientName].totalItems += order.Quantidade;
clientMap[clientName].uniqueOrders.add(`${order.Data_Pedido}_${order.Valor_Pedido}`);
const orderTime = parseOrderDate(order.Data_Pedido).getTime();
if (orderTime > clientMap[clientName].lastPurchase) {
clientMap[clientName].lastPurchase = orderTime;
}
});
const normalizedSearch = searchTerm.trim().toLowerCase();
const clients = Object.keys(clientMap).map(name => ({
name,
phone: clientMap[name].phone,
totalSpent: clientMap[name].totalSpent,
totalItems: clientMap[name].totalItems,
orderCount: clientMap[name].uniqueOrders.size,
lastPurchase: clientMap[name].lastPurchase
}));
const filteredClients = normalizedSearch
? clients.filter(client => client.name.toLowerCase().includes(normalizedSearch))
: clients;
return filteredClients.sort((a, b) => {
switch (sortBy) {
case 'recent': return b.lastPurchase - a.lastPurchase;
case 'spent_desc': return b.totalSpent - a.totalSpent;
case 'spent_asc': return a.totalSpent - b.totalSpent;
case 'items_desc': return b.totalItems - a.totalItems;
case 'items_asc': return a.totalItems - b.totalItems;
default: return 0;
}
});
};
export const buildClientDetailsMetrics = (ordersData: OrderData[], clientName: string): ClientDetailsMetrics => {
const clientOrders = ordersData.filter(order => getClientDisplayName(order) === clientName);
const groupedOrdersMap: Record<string, GroupedClientOrder> = {};
let clientPhone = '';
let totalSpent = 0;
let totalItems = 0;
clientOrders.forEach(order => {
if (order.Fone_Cliente && !clientPhone) clientPhone = order.Fone_Cliente;
totalSpent += getOrderItemRevenue(order);
totalItems += order.Quantidade;
const key = order.ID_Pedido || `${order.Data_Pedido}_${order.Valor_Pedido}`;
if (!groupedOrdersMap[key]) {
groupedOrdersMap[key] = {
date: order.Data_Pedido,
orderId: order.ID_Pedido || key,
orderTotal: order.Valor_Pedido,
items: []
};
}
groupedOrdersMap[key].items.push(order);
});
const groupedOrders = Object.values(groupedOrdersMap).sort((a, b) => {
return parseOrderDate(b.date).getTime() - parseOrderDate(a.date).getTime();
});
return { groupedOrders, totalSpent, totalItems, clientPhone };
};

110
src/analytics/dashboard.ts Normal file
View File

@@ -0,0 +1,110 @@
import type { DashboardAnalytics, DateRange, OrderData } from '../types';
import { filterOrdersByDateRange, getBaseProductName, getOrderItemRevenue } from './orders';
const COLORS = [
'#10b981', '#3b82f6', '#8b5cf6', '#f43f5e', '#f97316',
'#06b6d4', '#ec4899', '#eab308', '#6366f1', '#14b8a6',
'#6ee7b7', '#93c5fd', '#c4b5fd', '#fda4af', '#fdba74',
'#67e8f9', '#f9a8d4', '#fde047', '#a5b4fc', '#5eead4'
];
const globalColorMap: Record<string, string> = {};
let globalColorIndex = 0;
const getProductColor = (name: string): string => {
if (!globalColorMap[name]) {
globalColorMap[name] = COLORS[globalColorIndex % COLORS.length];
globalColorIndex += 1;
}
return globalColorMap[name];
};
export interface ChartProductMetric {
name: string;
id: string;
value: number;
fill: string;
}
export interface DashboardMetrics {
totalRevenue: number;
totalOrders: number;
averageOrderValue: number;
salesByProduct: ChartProductMetric[];
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;
let totalItems = 0;
const productSalesMap: Record<string, number> = {};
const productRevenueMap: Record<string, number> = {};
const productNameIdMap: Record<string, string> = {};
filteredData.forEach(order => {
const itemRevenue = getOrderItemRevenue(order);
const productName = getBaseProductName(order.Descricao_Produto);
revenue += itemRevenue;
totalItems += order.Quantidade;
productNameIdMap[productName] = order.ID_Produto;
productSalesMap[productName] = (productSalesMap[productName] || 0) + order.Quantidade;
productRevenueMap[productName] = (productRevenueMap[productName] || 0) + itemRevenue;
});
const topSalesNames = Object.keys(productSalesMap).sort((a, b) => productSalesMap[b] - productSalesMap[a]).slice(0, 10);
const topRevenueNames = Object.keys(productRevenueMap).sort((a, b) => productRevenueMap[b] - productRevenueMap[a]).slice(0, 10);
const displayProducts = Array.from(new Set([...topSalesNames, ...topRevenueNames])).sort();
const productColors = displayProducts.reduce<Record<string, string>>((colors, name) => {
colors[name] = getProductColor(name);
return colors;
}, {});
const salesByProduct = topSalesNames.map(name => ({
name,
id: productNameIdMap[name],
value: productSalesMap[name],
fill: productColors[name]
}));
const revenueByProduct = topRevenueNames.map(name => ({
name,
id: productNameIdMap[name],
value: productRevenueMap[name],
fill: productColors[name]
}));
return {
totalRevenue: revenue,
totalOrders: totalItems,
averageOrderValue: revenue / (filteredData.length || 1),
salesByProduct,
revenueByProduct
};
};

30
src/analytics/orders.ts Normal file
View File

@@ -0,0 +1,30 @@
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 => {
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 => {
return order.Nome_Cliente || `Cliente Desconhecido (Pedido ${order.Valor_Pedido})`;
};
export const isOrderInDateRange = (order: OrderData, dateRange: DateRange): boolean => {
const orderDate = parseOrderDate(order.Data_Pedido);
return orderDate >= dateRange.start && orderDate <= dateRange.end;
};
export const filterOrdersByDateRange = (orders: OrderData[], dateRange: DateRange): OrderData[] => {
return orders.filter(order => isOrderInDateRange(order, dateRange));
};
export const getOrderItemRevenue = (order: OrderData): number => {
return order.Quantidade * order.Valor_Unitario;
};

117
src/analytics/products.ts Normal file
View File

@@ -0,0 +1,117 @@
import type { DateRange, OrderData, StockData } from '../types';
import { getBaseProductName, getOrderItemRevenue, isOrderInDateRange } from './orders';
export interface ProductSummary {
id: string;
name: string;
totalSold: number;
revenue: number;
lastPrice: number;
stock: number;
}
export interface ProductDetailsMetrics {
productInfo: {
id: string;
name: string;
price: number;
} | null;
chartData: Array<{
date: string;
value: number;
}>;
totalSold: number;
totalRevenue: number;
}
export const buildProductsSummary = (
ordersData: OrderData[],
stockData: StockData[],
dateRange: DateRange,
searchTerm: string
): ProductSummary[] => {
const productMap: Record<string, ProductSummary> = {};
stockData.forEach(item => {
productMap[item.produto_id] = {
id: item.produto_id,
name: item.nome,
totalSold: 0,
revenue: 0,
lastPrice: 0,
stock: item.saldo || 0
};
});
ordersData.forEach(order => {
if (!isOrderInDateRange(order, dateRange)) return;
if (!productMap[order.ID_Produto]) {
productMap[order.ID_Produto] = {
id: order.ID_Produto,
name: getBaseProductName(order.Descricao_Produto),
totalSold: 0,
revenue: 0,
lastPrice: order.Valor_Unitario,
stock: 0
};
}
if (productMap[order.ID_Produto].name === 'Unknown' || !productMap[order.ID_Produto].name) {
productMap[order.ID_Produto].name = getBaseProductName(order.Descricao_Produto);
}
productMap[order.ID_Produto].totalSold += order.Quantidade;
productMap[order.ID_Produto].revenue += getOrderItemRevenue(order);
productMap[order.ID_Produto].lastPrice = order.Valor_Unitario;
});
const normalizedSearch = searchTerm.trim().toLowerCase();
const products = Object.values(productMap);
const filteredProducts = normalizedSearch
? products.filter(product => product.name.toLowerCase().includes(normalizedSearch) || product.id.includes(searchTerm))
: products;
return filteredProducts.sort((a, b) => b.totalSold - a.totalSold);
};
export const buildProductDetailsMetrics = (
ordersData: OrderData[],
productId: string | undefined,
dateRange: DateRange
): ProductDetailsMetrics => {
const productOrders = ordersData.filter(order => order.ID_Produto === productId);
if (productOrders.length === 0) {
return { productInfo: null, chartData: [], totalSold: 0, totalRevenue: 0 };
}
const productInfo = {
id: productOrders[0].ID_Produto,
name: getBaseProductName(productOrders[0].Descricao_Produto),
price: productOrders[0].Valor_Unitario
};
const salesByDate: Record<string, number> = {};
let totalSold = 0;
let totalRevenue = 0;
productOrders.forEach(order => {
if (!isOrderInDateRange(order, dateRange)) return;
salesByDate[order.Data_Pedido] = (salesByDate[order.Data_Pedido] || 0) + order.Quantidade;
totalSold += order.Quantidade;
totalRevenue += getOrderItemRevenue(order);
});
const chartData = Object.keys(salesByDate).map(date => ({
date,
value: salesByDate[date]
})).sort((a, b) => {
const [da, ma, ya] = a.date.split('-').map(Number);
const [db, mb, yb] = b.date.split('-').map(Number);
return new Date(ya, ma - 1, da).getTime() - new Date(yb, mb - 1, db).getTime();
});
return { productInfo, chartData, totalSold, totalRevenue };
};

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

Before

Width:  |  Height:  |  Size: 4.0 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 8.5 KiB

View File

@@ -76,7 +76,7 @@ const DateRangePicker: React.FC<DateRangePickerProps> = ({ dateRange, onChange,
} else { } else {
ref.current.focus(); ref.current.focus();
} }
} catch (e) { } catch {
ref.current.focus(); ref.current.focus();
} }
} }

View File

@@ -1,11 +1,12 @@
import { useState, useEffect } from 'react'; import { useCallback, useState, useEffect } from 'react';
import { Outlet, Link, useLocation } from 'react-router-dom'; import { Outlet, Link, useLocation } from 'react-router-dom';
import { LayoutDashboard, Users, BarChart3, ChevronLeft, ChevronRight, Package, Loader2, LogOut } from 'lucide-react'; import { LayoutDashboard, Users, BarChart3, ChevronLeft, ChevronRight, Package, Loader2, LogOut, Megaphone } from 'lucide-react';
import type { DateRange, OrderData } from '../types'; import type { DateRange, OrderData, StockData } from '../types';
import { fetchData, logout } from '../dataService'; import { fetchData, fetchStock, logout } from '../dataService';
const Layout = () => { const Layout = () => {
const location = useLocation(); const location = useLocation();
const needsRawData = location.pathname.startsWith('/products') || location.pathname.startsWith('/clients');
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(() => { const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(() => {
return localStorage.getItem('graph_sidebar_collapsed') === 'true'; return localStorage.getItem('graph_sidebar_collapsed') === 'true';
}); });
@@ -25,32 +26,41 @@ const Layout = () => {
}); });
const [ordersData, setOrdersData] = useState<OrderData[]>([]); const [ordersData, setOrdersData] = useState<OrderData[]>([]);
const [isLoading, setIsLoading] = useState(true); const [stockData, setStockData] = useState<StockData[]>([]);
const [isLoading, setIsLoading] = useState(needsRawData);
const [refreshInterval, setRefreshInterval] = useState<number>(() => { const [refreshInterval, setRefreshInterval] = useState<number>(() => {
const saved = localStorage.getItem('nexstar_refresh_interval'); const saved = localStorage.getItem('nexstar_refresh_interval');
return saved ? Number(saved) : 0; return saved ? Number(saved) : 0;
}); });
const loadData = async (showLoading = false) => { const loadData = useCallback(async (showLoading = false) => {
if (showLoading) setIsLoading(true); if (showLoading) setIsLoading(true);
const data = await fetchData(); try {
const [data, stock] = await Promise.all([fetchData(), fetchStock()]);
setOrdersData(data); setOrdersData(data);
setStockData(stock);
} finally {
if (showLoading) setIsLoading(false); if (showLoading) setIsLoading(false);
}; }
useEffect(() => {
loadData(true);
}, []); }, []);
useEffect(() => { 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(() => { const intervalId = setInterval(() => {
loadData(false); loadData(false);
}, refreshInterval); }, refreshInterval);
return () => clearInterval(intervalId); return () => clearInterval(intervalId);
}, [refreshInterval]); }, [loadData, needsRawData, refreshInterval]);
useEffect(() => { useEffect(() => {
localStorage.setItem('nexstar_refresh_interval', refreshInterval.toString()); localStorage.setItem('nexstar_refresh_interval', refreshInterval.toString());
@@ -73,6 +83,7 @@ const Layout = () => {
{ name: 'Dashboard', href: '/graph', icon: LayoutDashboard }, { name: 'Dashboard', href: '/graph', icon: LayoutDashboard },
{ name: 'Produtos', href: '/products', icon: Package }, { name: 'Produtos', href: '/products', icon: Package },
{ name: 'Clientes', href: '/clients', icon: Users }, { name: 'Clientes', href: '/clients', icon: Users },
{ name: 'Campanhas', href: '/campaigns', icon: Megaphone },
]; ];
return ( return (
@@ -141,13 +152,13 @@ const Layout = () => {
{/* Content Area */} {/* Content Area */}
<div className="flex-1 overflow-y-auto p-8 relative"> <div className="flex-1 overflow-y-auto p-8 relative">
{isLoading ? ( {needsRawData && isLoading && (
<div className="flex items-center justify-center h-full"> <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="w-8 h-8 text-brand-primary animate-spin" /> <Loader2 className="h-4 w-4 animate-spin text-brand-primary" />
Atualizando dados
</div> </div>
) : (
<Outlet context={{ dateRange, setDateRange, ordersData, refreshInterval, setRefreshInterval, loadData }} />
)} )}
<Outlet context={{ dateRange, setDateRange, ordersData, stockData, isDataLoading: needsRawData && isLoading, refreshInterval, setRefreshInterval, loadData }} />
</div> </div>
</main> </main>
</div> </div>

View File

@@ -1,146 +0,0 @@
[
{
"Nome_Cliente": "Luiz Felipe Oliveira Silva",
"Data_Pedido": "30-04-2026",
"Valor_Pedido": 19.9,
"ID_Produto": "951438842",
"Descricao_Produto": "IMPRESSÃO DTF PERSONALIZADO 57X100 (1 METRO)",
"Quantidade": 1,
"Valor_Unitario": 19.9
},
{
"Nome_Cliente": "61.855.899 WALTER PONCE JUNIOR",
"Data_Pedido": "30-04-2026",
"Valor_Pedido": 69.65,
"ID_Produto": "951438842",
"Descricao_Produto": "IMPRESSÃO DTF PERSONALIZADO 57X100 (1 METRO)",
"Quantidade": 3,
"Valor_Unitario": 19.9
},
{
"Nome_Cliente": "61.855.899 WALTER PONCE JUNIOR",
"Data_Pedido": "30-04-2026",
"Valor_Pedido": 69.65,
"ID_Produto": "978770094",
"Descricao_Produto": "IMPRESSÃO DTF PERSONALIZADO 57X0,50 (50 CENTÍMETROS)",
"Quantidade": 1,
"Valor_Unitario": 9.95
},
{
"Nome_Cliente": "Guilherme de Souza",
"Data_Pedido": "30-04-2026",
"Valor_Pedido": 65.66,
"ID_Produto": "951438842",
"Descricao_Produto": "IMPRESSÃO DTF PERSONALIZADO 57X100 (1 METRO)",
"Quantidade": 3,
"Valor_Unitario": 20.51889
},
{
"Nome_Cliente": "Guilherme de Souza",
"Data_Pedido": "30-04-2026",
"Valor_Pedido": 65.66,
"ID_Produto": "978776637",
"Descricao_Produto": "IMPRESSÃO DTF PERSONALIZADO 57X0,20 (20 CENTÍMETROS)",
"Quantidade": 1,
"Valor_Unitario": 4.103778
},
{
"Nome_Cliente": "Guilherme de Souza",
"Data_Pedido": "30-04-2026",
"Valor_Pedido": 24.54,
"ID_Produto": "919483299",
"Descricao_Produto": "BASE LISA CAMISETA COR PRETO TAMANHO - P",
"Quantidade": 1,
"Valor_Unitario": 12.27009
},
{
"Nome_Cliente": "Guilherme de Souza",
"Data_Pedido": "30-04-2026",
"Valor_Pedido": 24.54,
"ID_Produto": "919483307",
"Descricao_Produto": "BASE LISA CAMISETA COR PRETO TAMANHO - G",
"Quantidade": 1,
"Valor_Unitario": 12.27009
},
{
"Nome_Cliente": "61.855.899 WALTER PONCE JUNIOR",
"Data_Pedido": "30-04-2026",
"Valor_Pedido": 95.2,
"ID_Produto": "919483303",
"Descricao_Produto": "BASE LISA CAMISETA COR PRETO TAMANHO - M",
"Quantidade": 2,
"Valor_Unitario": 11.9
},
{
"Nome_Cliente": "61.855.899 WALTER PONCE JUNIOR",
"Data_Pedido": "30-04-2026",
"Valor_Pedido": 95.2,
"ID_Produto": "919483311",
"Descricao_Produto": "BASE LISA CAMISETA COR PRETO TAMANHO - GG",
"Quantidade": 2,
"Valor_Unitario": 11.9
},
{
"Nome_Cliente": "61.855.899 WALTER PONCE JUNIOR",
"Data_Pedido": "30-04-2026",
"Valor_Pedido": 95.2,
"ID_Produto": "976044109",
"Descricao_Produto": "BASE LISA CAMISETA COR MARINHO TAMANHO - GG",
"Quantidade": 2,
"Valor_Unitario": 11.9
},
{
"Nome_Cliente": "61.855.899 WALTER PONCE JUNIOR",
"Data_Pedido": "30-04-2026",
"Valor_Pedido": 95.2,
"ID_Produto": "919483255",
"Descricao_Produto": "BASE LISA CAMISETA COR PEROLA TAMANHO - G",
"Quantidade": 1,
"Valor_Unitario": 11.9
},
{
"Nome_Cliente": "61.855.899 WALTER PONCE JUNIOR",
"Data_Pedido": "30-04-2026",
"Valor_Pedido": 95.2,
"ID_Produto": "919483299",
"Descricao_Produto": "BASE LISA CAMISETA COR PRETO TAMANHO - P",
"Quantidade": 1,
"Valor_Unitario": 11.9
},
{
"Nome_Cliente": "Francieli Campos",
"Data_Pedido": "30-04-2026",
"Valor_Pedido": 224.95,
"ID_Produto": "978761156",
"Descricao_Produto": "IMPRESSÃO DTF PERSONALIZADO 57X100 (10 METROS)",
"Quantidade": 1,
"Valor_Unitario": 199
},
{
"Nome_Cliente": "Daniela Gutierrez",
"Data_Pedido": "30-04-2026",
"Valor_Pedido": 217.45,
"ID_Produto": "951438842",
"Descricao_Produto": "IMPRESSÃO DTF PERSONALIZADO 57X100 (1 METRO)",
"Quantidade": 10,
"Valor_Unitario": 19.9
},
{
"Nome_Cliente": "Joyce Pretel",
"Data_Pedido": "30-04-2026",
"Valor_Pedido": 22.47,
"ID_Produto": "951540701",
"Descricao_Produto": "BONÉ - PRETO",
"Quantidade": 3,
"Valor_Unitario": 7.49
},
{
"Nome_Cliente": "61.855.899 WALTER PONCE JUNIOR",
"Data_Pedido": "30-04-2026",
"Valor_Pedido": 59.7,
"ID_Produto": "951438842",
"Descricao_Produto": "IMPRESSÃO DTF PERSONALIZADO 57X100 (1 METRO)",
"Quantidade": 3,
"Valor_Unitario": 19.9
}
]

View File

@@ -1,4 +1,4 @@
import type { OrderData } from './types'; import type { CampaignPreview, CampaignProcessSummary, CampaignQueueSummary, DashboardAnalytics, DateRange, OrderData, StockData } from './types';
const API_URL = import.meta.env.VITE_API_URL || '/api'; const API_URL = import.meta.env.VITE_API_URL || '/api';
@@ -33,6 +33,25 @@ export const isAuthenticated = (): boolean => {
return !!localStorage.getItem('auth_token'); return !!localStorage.getItem('auth_token');
}; };
export const fetchStock = async (): Promise<StockData[]> => {
try {
const token = localStorage.getItem('auth_token');
const response = await fetch(`${API_URL}/stock`, {
headers: {
'Authorization': `Bearer ${token}`
}
});
if (response.status === 401 || response.status === 403) {
logout();
return [];
}
if (!response.ok) return [];
return await response.json();
} catch {
return [];
}
};
export const fetchData = async (): Promise<OrderData[]> => { export const fetchData = async (): Promise<OrderData[]> => {
try { try {
const token = localStorage.getItem('auth_token'); const token = localStorage.getItem('auth_token');
@@ -53,6 +72,96 @@ export const fetchData = async (): Promise<OrderData[]> => {
} }
}; };
const authFetch = async (path: string, options: RequestInit = {}): Promise<Response> => {
const token = localStorage.getItem('auth_token');
const response = await fetch(`${API_URL}${path}`, {
...options,
headers: {
...(options.headers || {}),
'Authorization': `Bearer ${token}`
}
});
if (response.status === 401 || response.status === 403) {
logout();
}
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');
if (!response.ok) return null;
return await response.json();
} catch (error) {
console.error('Fetch campaigns failed', error);
return null;
}
};
export const fetchCampaignPreview = async (): Promise<CampaignPreview | null> => {
try {
const response = await authFetch('/campaigns/preview');
if (!response.ok) return null;
return await response.json();
} catch (error) {
console.error('Fetch campaign preview failed', error);
return null;
}
};
export const processCampaignsNow = async (): Promise<CampaignProcessSummary | null> => {
try {
const response = await authFetch('/campaigns/process', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({})
});
if (!response.ok) return null;
return await response.json();
} catch (error) {
console.error('Process campaigns failed', error);
return null;
}
};
export const retryCampaignGroup = async (baseProductName: string): Promise<boolean> => {
try {
const response = await authFetch('/campaigns/retry', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ baseProductName })
});
return response.ok;
} catch (error) {
console.error('Retry campaign failed', error);
return false;
}
};
export const parseOrderDate = (dateStr: string): Date => { export const parseOrderDate = (dateStr: string): Date => {
if (!dateStr) return new Date(0); if (!dateStr) return new Date(0);
if (dateStr.includes('T')) return new Date(dateStr); if (dateStr.includes('T')) return new Date(dateStr);
@@ -70,7 +179,7 @@ export const parseOrderDate = (dateStr: string): Date => {
return isNaN(fallback.getTime()) ? new Date(0) : fallback; 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; if (!data || !data.length) return;
const headers = Object.keys(data[0]); const headers = Object.keys(data[0]);
@@ -83,7 +192,7 @@ export const exportToCSV = (data: any[], filename: string) => {
for (const row of data) { for (const row of data) {
const values = headers.map(header => { const values = headers.map(header => {
const val = row[header]; const val = row[header];
const escaped = ('' + val).replace(/"/g, '\\"'); const escaped = String(val ?? '').replace(/"/g, '""');
return `"${escaped}"`; return `"${escaped}"`;
}); });
csvRows.push(values.join(',')); csvRows.push(values.join(','));

View File

@@ -1,3 +1,4 @@
import '@vitejs/plugin-react/preamble'
import { StrictMode } from 'react' import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client' import { createRoot } from 'react-dom/client'
import { HashRouter } from 'react-router-dom' import { HashRouter } from 'react-router-dom'

253
src/pages/Campaigns.tsx Normal file
View File

@@ -0,0 +1,253 @@
import { useEffect, useMemo, useState } from 'react';
import { AlertTriangle, CheckCircle2, Clock, Megaphone, RefreshCw, RotateCcw, Send, XCircle } from 'lucide-react';
import type { CampaignGroup, CampaignPreview, CampaignProcessSummary, CampaignQueueSummary, CampaignStatus } from '../types';
import { fetchCampaignPreview, fetchCampaigns, processCampaignsNow, retryCampaignGroup } from '../dataService';
const statusLabels: Record<CampaignStatus, string> = {
pending: 'Pendente',
processing: 'Processando',
sent: 'Enviada',
failed: 'Falhou',
skipped: 'Ignorada'
};
const statusStyles: Record<CampaignStatus, string> = {
pending: 'bg-yellow-500/10 text-yellow-400 border-yellow-500/20',
processing: 'bg-blue-500/10 text-blue-400 border-blue-500/20',
sent: 'bg-emerald-500/10 text-emerald-400 border-emerald-500/20',
failed: 'bg-red-500/10 text-red-400 border-red-500/20',
skipped: 'bg-zinc-500/10 text-zinc-400 border-zinc-500/20'
};
const statusIcons: Record<CampaignStatus, typeof Clock> = {
pending: Clock,
processing: RefreshCw,
sent: CheckCircle2,
failed: XCircle,
skipped: AlertTriangle
};
const formatDate = (value?: string | null) => {
if (!value) return '-';
return new Date(value).toLocaleString('pt-BR');
};
const formatDelta = (value: number) => `${value} un.`;
const Campaigns = () => {
const [summary, setSummary] = useState<CampaignQueueSummary | null>(null);
const [preview, setPreview] = useState<CampaignPreview | null>(null);
const [processResult, setProcessResult] = useState<CampaignProcessSummary | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [isProcessing, setIsProcessing] = useState(false);
const loadCampaigns = async () => {
setIsLoading(true);
const [campaignsData, previewData] = await Promise.all([
fetchCampaigns(),
fetchCampaignPreview()
]);
setSummary(campaignsData);
setPreview(previewData);
setIsLoading(false);
};
useEffect(() => {
// Campaign state is loaded from the backend after the protected route mounts.
// eslint-disable-next-line react-hooks/set-state-in-effect
void loadCampaigns();
}, []);
const groupedCounts = useMemo(() => {
const counts: Record<CampaignStatus, number> = {
pending: 0,
processing: 0,
sent: 0,
failed: 0,
skipped: 0
};
summary?.groups.forEach(group => {
counts[group.status] += 1;
});
return counts;
}, [summary]);
const handleProcessNow = async () => {
setIsProcessing(true);
const result = await processCampaignsNow();
setProcessResult(result);
await loadCampaigns();
setIsProcessing(false);
};
const handleRetry = async (group: CampaignGroup) => {
setIsProcessing(true);
await retryCampaignGroup(group.baseProductName);
await loadCampaigns();
setIsProcessing(false);
};
return (
<div className="space-y-6">
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-4">
<div>
<h1 className="text-2xl font-bold mb-2 text-dark-text">Campanhas</h1>
<p className="text-dark-muted font-medium">Fila de reposição, prévia de envio e histórico das campanhas do WhatsApp.</p>
</div>
<div className="flex flex-wrap gap-3">
<button
onClick={() => void loadCampaigns()}
disabled={isLoading || isProcessing}
className="inline-flex items-center gap-2 bg-dark-card border border-dark-border px-4 py-2.5 rounded-xl hover:border-brand-primary transition-colors text-sm font-medium text-dark-text disabled:opacity-50 cursor-pointer"
>
<RefreshCw size={16} className={isLoading ? 'animate-spin' : ''} />
Atualizar
</button>
<button
onClick={handleProcessNow}
disabled={isProcessing || !preview?.readyProducts.length}
className="inline-flex items-center gap-2 bg-brand-primary text-black px-4 py-2.5 rounded-xl hover:opacity-90 transition-opacity text-sm font-bold disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer"
>
<Send size={16} />
Processar agora
</button>
</div>
</div>
{processResult && (
<div className="bg-dark-card border border-dark-border rounded-2xl p-4 text-sm text-dark-muted">
Resultado: {processResult.claimed} itens processados, {processResult.sentGroups} grupos enviados, {processResult.failedGroups} falhas, {processResult.pendingBelowThresholdGroups} abaixo do limite.
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-5 gap-4">
{Object.entries(groupedCounts).map(([status, count]) => {
const typedStatus = status as CampaignStatus;
const Icon = statusIcons[typedStatus];
return (
<div key={status} className="bg-dark-card border border-dark-border rounded-2xl p-4">
<div className="flex items-center justify-between mb-3">
<span className="text-xs font-bold uppercase tracking-widest text-dark-muted">{statusLabels[typedStatus]}</span>
<Icon className="w-4 h-4 text-brand-primary" />
</div>
<p className="text-2xl font-bold text-dark-text">{count}</p>
</div>
);
})}
</div>
<div className="grid grid-cols-1 xl:grid-cols-2 gap-6">
<div className="bg-dark-card border border-dark-border rounded-2xl p-6">
<div className="flex items-center gap-2 mb-4">
<Megaphone className="w-5 h-5 text-brand-primary" />
<h2 className="text-lg font-bold text-dark-text">Prévia do próximo envio</h2>
</div>
<div className="space-y-4">
<div>
<p className="text-xs font-bold text-dark-muted uppercase tracking-widest mb-1">Produtos prontos</p>
<p className="text-dark-text font-semibold">{preview?.productsText || 'Nenhum produto atingiu o limite ainda.'}</p>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="bg-dark-input rounded-xl p-3 border border-dark-border">
<p className="text-xs text-dark-muted mb-1">Clientes alvo</p>
<p className="text-xl font-bold text-dark-text">{preview?.customerCount ?? 0}</p>
</div>
<div className="bg-dark-input rounded-xl p-3 border border-dark-border">
<p className="text-xs text-dark-muted mb-1">Limite por produto</p>
<p className="text-xl font-bold text-dark-text">{summary?.threshold ?? preview?.threshold ?? 100}</p>
</div>
</div>
<div className="space-y-2">
{(preview?.readyProducts || []).map(product => (
<div key={product.baseProduct} className="flex justify-between gap-4 border border-emerald-500/20 bg-emerald-500/5 rounded-xl p-3">
<span className="font-semibold text-dark-text">{product.baseProduct}</span>
<span className="text-emerald-400 font-bold">{formatDelta(product.total_delta)}</span>
</div>
))}
{(preview?.belowThresholdProducts || []).map(product => (
<div key={product.baseProduct} className="flex justify-between gap-4 border border-dark-border bg-dark-input rounded-xl p-3">
<span className="font-semibold text-dark-muted">{product.baseProduct}</span>
<span className="text-yellow-400 font-bold">{formatDelta(product.total_delta)}</span>
</div>
))}
</div>
</div>
</div>
<div className="bg-dark-card border border-dark-border rounded-2xl p-6">
<h2 className="text-lg font-bold text-dark-text mb-4">Top clientes da campanha</h2>
<div className="space-y-2">
{(preview?.customersPreview || []).map(customer => (
<div key={`${customer.fone}-${customer.nome}`} className="flex items-center justify-between gap-4 border border-dark-border rounded-xl p-3">
<div className="min-w-0">
<p className="font-semibold text-dark-text truncate">{customer.nome}</p>
<p className="text-xs text-dark-muted">{customer.fone}</p>
</div>
<span className="text-xs font-bold text-brand-primary shrink-0">{customer.total_comprado || 0} un.</span>
</div>
))}
{!preview?.customersPreview.length && <p className="text-dark-muted text-sm">Nenhum cliente com telefone válido encontrado.</p>}
</div>
</div>
</div>
<div className="bg-dark-card border border-dark-border rounded-2xl overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-left text-sm">
<thead className="bg-dark-header border-b border-dark-border text-dark-muted">
<tr>
<th className="px-6 py-4 font-bold uppercase tracking-wider text-[10px]">Produto</th>
<th className="px-6 py-4 font-bold uppercase tracking-wider text-[10px]">Status</th>
<th className="px-6 py-4 font-bold uppercase tracking-wider text-[10px]">Delta</th>
<th className="px-6 py-4 font-bold uppercase tracking-wider text-[10px]">Itens</th>
<th className="px-6 py-4 font-bold uppercase tracking-wider text-[10px]">Tentativas</th>
<th className="px-6 py-4 font-bold uppercase tracking-wider text-[10px]">Atualizado</th>
<th className="px-6 py-4 font-bold uppercase tracking-wider text-[10px] text-right">Ações</th>
</tr>
</thead>
<tbody className="divide-y divide-dark-border">
{(summary?.groups || []).map(group => {
const Icon = statusIcons[group.status];
return (
<tr key={group.key} className="hover:bg-dark-input/40 transition-colors">
<td className="px-6 py-3">
<p className="font-semibold text-dark-text">{group.baseProductName}</p>
{group.lastError && <p className="text-xs text-red-400 mt-1 max-w-md truncate">{group.lastError}</p>}
</td>
<td className="px-6 py-3">
<span className={`inline-flex items-center gap-1.5 border px-2.5 py-1 rounded-full text-xs font-bold ${statusStyles[group.status]}`}>
<Icon className="w-3.5 h-3.5" />
{statusLabels[group.status]}
</span>
</td>
<td className="px-6 py-3 font-bold text-dark-text">{formatDelta(group.totalDelta)}</td>
<td className="px-6 py-3 text-dark-muted">{group.rowCount}</td>
<td className="px-6 py-3 text-dark-muted">{group.attempts}</td>
<td className="px-6 py-3 text-dark-muted whitespace-nowrap">{formatDate(group.updatedAt)}</td>
<td className="px-6 py-3 text-right">
{(group.status === 'failed' || group.status === 'skipped') && (
<button
onClick={() => void handleRetry(group)}
disabled={isProcessing}
className="inline-flex items-center gap-1.5 text-xs font-bold text-brand-primary hover:opacity-80 disabled:opacity-50 cursor-pointer"
>
<RotateCcw className="w-3.5 h-3.5" />
Reprocessar
</button>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
{!summary?.groups.length && !isLoading && (
<div className="p-8 text-center text-dark-muted">Nenhuma campanha registrada ainda.</div>
)}
</div>
</div>
);
};
export default Campaigns;

View File

@@ -1,54 +1,30 @@
import { useMemo } from 'react'; import { useMemo } from 'react';
import { useParams, Link, useOutletContext } from 'react-router-dom'; import { useParams, Link, useOutletContext } from 'react-router-dom';
import { ArrowLeft, User, Tag, Package, DollarSign, Clock } from 'lucide-react'; import { ArrowLeft, User, Tag, Package, DollarSign, Clock, Phone } from 'lucide-react';
import type { OrderData } from '../types'; import type { OrderData } from '../types';
import { parseOrderDate } from '../dataService'; import { buildClientDetailsMetrics } from '../analytics/clients';
const ClientDetails = () => { const ClientDetails = () => {
const { name } = useParams<{ name: string }>(); const { name } = useParams<{ name: string }>();
const decodedName = name ? decodeURIComponent(name) : ''; const decodedName = name ? decodeURIComponent(name) : '';
const { ordersData } = useOutletContext<{ ordersData: OrderData[] }>(); const { ordersData, isDataLoading } = useOutletContext<{ ordersData: OrderData[], isDataLoading: boolean }>();
const { groupedOrders, totalSpent, totalItems } = useMemo(() => { const { groupedOrders, totalSpent, totalItems, clientPhone } = useMemo(() => {
const orders = ordersData; return buildClientDetailsMetrics(ordersData, decodedName);
const clientOrders = orders.filter(order => {
const clientName = order.Nome_Cliente || `Cliente Desconhecido (Pedido ${order.Valor_Pedido})`;
return clientName === decodedName;
});
const groupedOrdersMap: Record<string, { date: string, orderId: string, orderTotal: number, items: OrderData[] }> = {};
let totalSpent = 0;
let totalItems = 0;
clientOrders.forEach(order => {
totalSpent += (order.Quantidade * order.Valor_Unitario);
totalItems += order.Quantidade;
// Use ID_Pedido if available, otherwise fallback to date and total order value
const key = order.ID_Pedido || `${order.Data_Pedido}_${order.Valor_Pedido}`;
if (!groupedOrdersMap[key]) {
groupedOrdersMap[key] = {
date: order.Data_Pedido,
orderId: order.ID_Pedido || key,
orderTotal: order.Valor_Pedido,
items: []
};
}
groupedOrdersMap[key].items.push(order);
});
// Sort grouped orders by date descending
const groupedOrders = Object.values(groupedOrdersMap).sort((a, b) => {
return parseOrderDate(b.date).getTime() - parseOrderDate(a.date).getTime();
});
return { groupedOrders, totalSpent, totalItems };
}, [decodedName, ordersData]); }, [decodedName, ordersData]);
const formatCurrency = (value: number) => { const formatCurrency = (value: number) => {
return new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(value); 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) { if (!groupedOrders.length) {
return ( return (
<div className="text-center py-12"> <div className="text-center py-12">
@@ -74,7 +50,18 @@ const ClientDetails = () => {
</div> </div>
<div> <div>
<h1 className="text-2xl font-bold text-zinc-900 dark:text-dark-text">{decodedName}</h1> <h1 className="text-2xl font-bold text-zinc-900 dark:text-dark-text">{decodedName}</h1>
<div className="flex items-center gap-3 mt-1">
<p className="text-zinc-500 dark:text-dark-muted font-medium">Histórico completo de compras</p> <p className="text-zinc-500 dark:text-dark-muted font-medium">Histórico completo de compras</p>
{clientPhone && (
<>
<span className="text-zinc-300 dark:text-dark-border"></span>
<span className="flex items-center gap-1.5 text-brand-primary font-bold text-sm bg-brand-primary/10 px-2 py-1 rounded-md">
<Phone className="w-3.5 h-3.5" />
{clientPhone}
</span>
</>
)}
</div>
</div> </div>
</div> </div>

View File

@@ -1,11 +1,10 @@
import { useMemo, useState, useEffect } from 'react'; import { useMemo, useState } from 'react';
import { Link, useOutletContext } from 'react-router-dom'; import { Link, useOutletContext } from 'react-router-dom';
import { Search, ChevronRight, Filter, ChevronLeft, Download } from 'lucide-react'; import { Search, ChevronRight, Filter, ChevronLeft, Download } from 'lucide-react';
import type { OrderData, DateRange } from '../types'; import type { OrderData, DateRange } from '../types';
import { parseOrderDate, exportToCSV } from '../dataService'; import { exportToCSV } from '../dataService';
import DateRangePicker from '../components/DateRangePicker'; import DateRangePicker from '../components/DateRangePicker';
import { buildClientsSummary, type ClientSortOption } from '../analytics/clients';
type SortOption = 'recent' | 'spent_desc' | 'spent_asc' | 'items_desc' | 'items_asc';
const Clients = () => { const Clients = () => {
const { dateRange, setDateRange, ordersData } = useOutletContext<{ const { dateRange, setDateRange, ordersData } = useOutletContext<{
@@ -14,66 +13,14 @@ const Clients = () => {
ordersData: OrderData[] ordersData: OrderData[]
}>(); }>();
const [searchTerm, setSearchTerm] = useState(''); const [searchTerm, setSearchTerm] = useState('');
const [sortBy, setSortBy] = useState<SortOption>('recent'); const [sortBy, setSortBy] = useState<ClientSortOption>('recent');
// Pagination state // Pagination state
const [currentPage, setCurrentPage] = useState(1); const [currentPage, setCurrentPage] = useState(1);
const [itemsPerPage, setItemsPerPage] = useState(10); 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 clientsData = useMemo(() => {
const orders = ordersData.filter(order => { return buildClientsSummary(ordersData, dateRange, searchTerm, sortBy);
const orderDate = parseOrderDate(order.Data_Pedido);
return orderDate >= dateRange.start && orderDate <= dateRange.end;
});
const clientMap: Record<string, { totalSpent: number, totalItems: number, uniqueOrders: Set<string>, lastPurchase: number }> = {};
orders.forEach(order => {
const clientName = order.Nome_Cliente || `Cliente Desconhecido (Pedido ${order.Valor_Pedido})`;
if (!clientMap[clientName]) {
clientMap[clientName] = { totalSpent: 0, totalItems: 0, uniqueOrders: new Set(), lastPurchase: 0 };
}
// Calculate total spent based on quantity * unit price
clientMap[clientName].totalSpent += (order.Quantidade * order.Valor_Unitario);
clientMap[clientName].totalItems += order.Quantidade;
clientMap[clientName].uniqueOrders.add(`${order.Data_Pedido}_${order.Valor_Pedido}`);
const orderTime = parseOrderDate(order.Data_Pedido).getTime();
if (orderTime > clientMap[clientName].lastPurchase) {
clientMap[clientName].lastPurchase = orderTime;
}
});
let result = Object.keys(clientMap).map(name => ({
name,
totalSpent: clientMap[name].totalSpent,
totalItems: clientMap[name].totalItems,
orderCount: clientMap[name].uniqueOrders.size, // Grouped by unique date+value combinations
lastPurchase: clientMap[name].lastPurchase
}));
if (searchTerm) {
result = result.filter(c => c.name.toLowerCase().includes(searchTerm.toLowerCase()));
}
return result.sort((a, b) => {
switch (sortBy) {
case 'recent': return b.lastPurchase - a.lastPurchase;
case 'spent_desc': return b.totalSpent - a.totalSpent;
case 'spent_asc': return a.totalSpent - b.totalSpent;
case 'items_desc': return b.totalItems - a.totalItems;
case 'items_asc': return a.totalItems - b.totalItems;
default: return 0;
}
});
}, [searchTerm, sortBy, ordersData, dateRange]); }, [searchTerm, sortBy, ordersData, dateRange]);
// Pagination logic // Pagination logic
@@ -96,14 +43,20 @@ const Clients = () => {
<div className="flex flex-col sm:flex-row flex-wrap gap-3 items-center justify-start xl:justify-end"> <div className="flex flex-col sm:flex-row flex-wrap gap-3 items-center justify-start xl:justify-end">
<DateRangePicker <DateRangePicker
dateRange={dateRange} dateRange={dateRange}
onChange={setDateRange} onChange={(range) => {
setDateRange(range);
setCurrentPage(1);
}}
/> />
<div className="relative"> <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" /> <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 <select
value={sortBy} value={sortBy}
onChange={(e) => setSortBy(e.target.value as SortOption)} onChange={(e) => {
setSortBy(e.target.value as ClientSortOption);
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" 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> <option value="recent">Mais Recentes</option>
@@ -120,7 +73,10 @@ const Clients = () => {
type="text" type="text"
placeholder="Buscar cliente..." placeholder="Buscar cliente..."
value={searchTerm} 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" 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> </div>
@@ -129,6 +85,7 @@ const Clients = () => {
onClick={() => { onClick={() => {
const exportData = clientsData.map(client => ({ const exportData = clientsData.map(client => ({
'Nome do Cliente': client.name, 'Nome do Cliente': client.name,
'Telefone/WhatsApp': client.phone || 'N/A',
'Total Gasto (R$)': client.totalSpent.toFixed(2).replace('.', ','), 'Total Gasto (R$)': client.totalSpent.toFixed(2).replace('.', ','),
'Produtos Comprados': client.totalItems, 'Produtos Comprados': client.totalItems,
'Total de Pedidos': client.orderCount, 'Total de Pedidos': client.orderCount,

View File

@@ -1,36 +1,33 @@
import { useMemo } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import { useOutletContext, useNavigate } from 'react-router-dom'; import { useOutletContext, useNavigate } from 'react-router-dom';
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, PieChart, Pie, Cell } from 'recharts'; 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 DateRangePicker from '../components/DateRangePicker';
import type { OrderData, DateRange } from '../types'; import type { DashboardAnalytics, OrderData, DateRange } from '../types';
import { parseOrderDate } from '../dataService'; import { applyDashboardColors, buildDashboardMetrics } from '../analytics/dashboard';
import { fetchDashboardAnalytics } from '../dataService';
const COLORS = [
// 10 Strong Base Colors
'#10b981', '#3b82f6', '#8b5cf6', '#f43f5e', '#f97316',
'#06b6d4', '#ec4899', '#eab308', '#6366f1', '#14b8a6',
// 10 Softer Versions
'#6ee7b7', '#93c5fd', '#c4b5fd', '#fda4af', '#fdba74',
'#67e8f9', '#f9a8d4', '#fde047', '#a5b4fc', '#5eead4'
];
const globalColorMap: Record<string, string> = {};
let globalColorIndex = 0;
const getProductColor = (name: string) => {
if (!globalColorMap[name]) {
globalColorMap[name] = COLORS[globalColorIndex % COLORS.length];
globalColorIndex++;
}
return globalColorMap[name];
};
const formatCurrency = (value: number) => { const formatCurrency = (value: number) => {
return new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(value); 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) { if (active && payload && payload.length) {
const color = payload[0].payload?.fill || payload[0].color || '#9ECAE1'; const color = payload[0].payload?.fill || payload[0].color || '#9ECAE1';
const displayLabel = label || payload[0].name; const displayLabel = label || payload[0].name;
@@ -48,74 +45,47 @@ const CustomTooltip = ({ active, payload, label, isCurrency }: any) => {
const Dashboard = () => { const Dashboard = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const { dateRange, setDateRange, ordersData, refreshInterval, setRefreshInterval, loadData } = useOutletContext<{ const { dateRange, setDateRange, ordersData, refreshInterval, setRefreshInterval } = useOutletContext<{
dateRange: DateRange, dateRange: DateRange,
setDateRange: (range: DateRange) => void, setDateRange: (range: DateRange) => void,
ordersData: OrderData[], ordersData: OrderData[],
refreshInterval: number, refreshInterval: number,
setRefreshInterval: (interval: number) => void, setRefreshInterval: (interval: number) => void,
loadData: (showLoading?: boolean) => void
}>(); }>();
const [serverMetrics, setServerMetrics] = useState<DashboardAnalytics | null>(null);
const [isMetricsLoading, setIsMetricsLoading] = useState(true);
const filteredData = useMemo(() => { const loadDashboardMetrics = useCallback(async (range: DateRange) => {
const orders = ordersData; setIsMetricsLoading(true);
return orders.filter(order => { const metrics = await fetchDashboardAnalytics(range);
const orderDate = parseOrderDate(order.Data_Pedido); setServerMetrics(metrics);
return orderDate >= dateRange.start && orderDate <= dateRange.end; setIsMetricsLoading(false);
}); }, []);
}, [dateRange, ordersData]);
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(() => { const { totalRevenue, totalOrders, averageOrderValue, salesByProduct, revenueByProduct } = useMemo(() => {
let revenue = 0; if (serverMetrics) return applyDashboardColors(serverMetrics);
let totalItems = 0; return buildDashboardMetrics(ordersData, dateRange);
const productSalesMap: Record<string, number> = {}; }, [dateRange, ordersData, serverMetrics]);
const productRevenueMap: Record<string, number> = {};
const productNameIdMap: Record<string, string> = {};
filteredData.forEach(order => { const handleManualRefresh = () => {
const itemRevenue = order.Quantidade * order.Valor_Unitario; void loadDashboardMetrics(dateRange);
revenue += itemRevenue; };
totalItems += order.Quantidade;
const productName = order.Descricao_Produto.split(' TAMANHO')[0];
productNameIdMap[productName] = order.ID_Produto;
if (productSalesMap[productName]) {
productSalesMap[productName] += order.Quantidade;
productRevenueMap[productName] += itemRevenue;
} else {
productSalesMap[productName] = order.Quantidade;
productRevenueMap[productName] = itemRevenue;
}
});
// Identify which products will actually be displayed in both charts (Top 10 of each)
const topSalesNames = Object.keys(productSalesMap).sort((a, b) => productSalesMap[b] - productSalesMap[a]).slice(0, 10);
const topRevenueNames = Object.keys(productRevenueMap).sort((a, b) => productRevenueMap[b] - productRevenueMap[a]).slice(0, 10);
// Combine them into a unique set to assign colors only to the VISIBLE products
const displayProducts = Array.from(new Set([...topSalesNames, ...topRevenueNames])).sort();
const productColors: Record<string, string> = {};
displayProducts.forEach((name) => {
productColors[name] = getProductColor(name);
});
const productsData = topSalesNames.map(name => ({
name,
id: productNameIdMap[name],
value: productSalesMap[name],
fill: productColors[name]
}));
const revenueData = topRevenueNames.map(name => ({
name,
id: productNameIdMap[name],
value: productRevenueMap[name],
fill: productColors[name]
}));
return { totalRevenue: revenue, totalOrders: totalItems, averageOrderValue: revenue / (filteredData.length || 1), salesByProduct: productsData, revenueByProduct: revenueData };
}, [filteredData]);
return ( return (
<div className="space-y-6"> <div className="space-y-6">
@@ -129,10 +99,17 @@ const Dashboard = () => {
onChange={setDateRange} onChange={setDateRange}
refreshInterval={refreshInterval} refreshInterval={refreshInterval}
setRefreshInterval={setRefreshInterval} setRefreshInterval={setRefreshInterval}
onManualRefresh={() => loadData(true)} onManualRefresh={handleManualRefresh}
/> />
</div> </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="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="bg-dark-card p-6 rounded-2xl border border-dark-border shadow-sm">
<div className="flex justify-between items-start"> <div className="flex justify-between items-start">

View File

@@ -22,7 +22,7 @@ const Login = () => {
} else { } else {
setError('E-mail ou senha incorretos.'); setError('E-mail ou senha incorretos.');
} }
} catch (err) { } catch {
setError('Erro ao conectar ao servidor.'); setError('Erro ao conectar ao servidor.');
} finally { } finally {
setIsLoading(false); setIsLoading(false);

View File

@@ -4,9 +4,15 @@ import { ArrowLeft, Package, DollarSign } from 'lucide-react';
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts'; import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
import DateRangePicker from '../components/DateRangePicker'; import DateRangePicker from '../components/DateRangePicker';
import type { OrderData, DateRange } from '../types'; import type { OrderData, DateRange } from '../types';
import { parseOrderDate } from '../dataService'; import { buildProductDetailsMetrics } from '../analytics/products';
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) { if (active && payload && payload.length) {
return ( return (
<div className="bg-[#141414] p-3 rounded-xl shadow-lg border-none"> <div className="bg-[#141414] p-3 rounded-xl shadow-lg border-none">
@@ -20,56 +26,32 @@ const CustomTooltip = ({ active, payload, label }: any) => {
const ProductDetails = () => { const ProductDetails = () => {
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
const { dateRange, setDateRange, ordersData } = useOutletContext<{ const { dateRange, setDateRange, ordersData, isDataLoading } = useOutletContext<{
dateRange: DateRange, dateRange: DateRange,
setDateRange: (range: DateRange) => void, setDateRange: (range: DateRange) => void,
ordersData: OrderData[], ordersData: OrderData[],
isDataLoading: boolean,
refreshInterval: number, refreshInterval: number,
setRefreshInterval: (interval: number) => void, setRefreshInterval: (interval: number) => void,
loadData: (showLoading?: boolean) => void loadData: (showLoading?: boolean) => void
}>(); }>();
const { productInfo, chartData, totalSold, totalRevenue } = useMemo(() => { const { productInfo, chartData, totalSold, totalRevenue } = useMemo(() => {
const orders = ordersData.filter(order => order.ID_Produto === id); return buildProductDetailsMetrics(ordersData, id, dateRange);
if (orders.length === 0) return { productInfo: null, chartData: [], totalSold: 0, totalRevenue: 0 };
const info = {
id: orders[0].ID_Produto,
name: orders[0].Descricao_Produto.split(' TAMANHO')[0],
price: orders[0].Valor_Unitario
};
const salesByDate: Record<string, number> = {};
let sold = 0;
let revenue = 0;
orders.forEach(order => {
const orderDate = parseOrderDate(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);
}
});
const chart = Object.keys(salesByDate).map(date => ({
date,
value: salesByDate[date]
})).sort((a, b) => {
const [da, ma, ya] = a.date.split('-').map(Number);
const [db, mb, yb] = b.date.split('-').map(Number);
return new Date(ya, ma - 1, da).getTime() - new Date(yb, mb - 1, db).getTime();
});
return { productInfo: info, chartData: chart, totalSold: sold, totalRevenue: revenue };
}, [id, dateRange, ordersData]); }, [id, dateRange, ordersData]);
const formatCurrency = (value: number) => { const formatCurrency = (value: number) => {
return new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(value); 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) { if (!productInfo) {
return ( return (
<div className="text-center py-12"> <div className="text-center py-12">

View File

@@ -1,15 +1,17 @@
import { useMemo, useState, useEffect } from 'react'; import { useMemo, useState } from 'react';
import { Link, useOutletContext } from 'react-router-dom'; import { Link, useOutletContext } from 'react-router-dom';
import { Search, Package, TrendingUp, ChevronLeft, ChevronRight, Download } from 'lucide-react'; import { Search, Package, TrendingUp, ChevronLeft, ChevronRight, Download } from 'lucide-react';
import DateRangePicker from '../components/DateRangePicker'; import DateRangePicker from '../components/DateRangePicker';
import type { OrderData, DateRange } from '../types'; import type { OrderData, DateRange, StockData } from '../types';
import { parseOrderDate, exportToCSV } from '../dataService'; import { exportToCSV } from '../dataService';
import { buildProductsSummary } from '../analytics/products';
const Products = () => { const Products = () => {
const { dateRange, setDateRange, ordersData } = useOutletContext<{ const { dateRange, setDateRange, ordersData, stockData } = useOutletContext<{
dateRange: DateRange, dateRange: DateRange,
setDateRange: (range: DateRange) => void, setDateRange: (range: DateRange) => void,
ordersData: OrderData[], ordersData: OrderData[],
stockData: StockData[],
refreshInterval: number, refreshInterval: number,
setRefreshInterval: (interval: number) => void, setRefreshInterval: (interval: number) => void,
loadData: (showLoading?: boolean) => void loadData: (showLoading?: boolean) => void
@@ -20,41 +22,9 @@ const Products = () => {
const [currentPage, setCurrentPage] = useState(1); const [currentPage, setCurrentPage] = useState(1);
const [itemsPerPage, setItemsPerPage] = useState(10); const [itemsPerPage, setItemsPerPage] = useState(10);
// Reset to first page when search or date range changes
useEffect(() => {
setCurrentPage(1);
}, [searchTerm, dateRange]);
const productsData = useMemo(() => { const productsData = useMemo(() => {
const orders = ordersData; return buildProductsSummary(ordersData, stockData, dateRange, searchTerm);
const productMap: Record<string, { id: string, name: string, totalSold: number, revenue: number, lastPrice: number }> = {}; }, [dateRange, searchTerm, ordersData, stockData]);
orders.forEach(order => {
const orderDate = parseOrderDate(order.Data_Pedido);
if (orderDate < dateRange.start || orderDate > dateRange.end) return;
if (!productMap[order.ID_Produto]) {
productMap[order.ID_Produto] = {
id: order.ID_Produto,
name: order.Descricao_Produto.split(' TAMANHO')[0],
totalSold: 0,
revenue: 0,
lastPrice: order.Valor_Unitario
};
}
productMap[order.ID_Produto].totalSold += order.Quantidade;
productMap[order.ID_Produto].revenue += (order.Quantidade * order.Valor_Unitario);
productMap[order.ID_Produto].lastPrice = order.Valor_Unitario;
});
let result = Object.values(productMap);
if (searchTerm) {
result = result.filter(p => p.name.toLowerCase().includes(searchTerm.toLowerCase()) || p.id.includes(searchTerm));
}
return result.sort((a, b) => b.totalSold - a.totalSold);
}, [dateRange, searchTerm, ordersData]);
// Pagination logic // Pagination logic
const totalPages = Math.ceil(productsData.length / itemsPerPage); const totalPages = Math.ceil(productsData.length / itemsPerPage);
@@ -76,7 +46,10 @@ const Products = () => {
<div className="flex flex-col sm:flex-row gap-3"> <div className="flex flex-col sm:flex-row gap-3">
<DateRangePicker <DateRangePicker
dateRange={dateRange} dateRange={dateRange}
onChange={setDateRange} onChange={(range) => {
setDateRange(range);
setCurrentPage(1);
}}
/> />
<div className="relative"> <div className="relative">
@@ -85,7 +58,10 @@ const Products = () => {
type="text" type="text"
placeholder="Buscar por nome ou ID..." placeholder="Buscar por nome ou ID..."
value={searchTerm} 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" 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> </div>
@@ -118,6 +94,7 @@ const Products = () => {
<th className="px-6 py-4 font-bold uppercase tracking-wider text-[10px]">ID Produto</th> <th className="px-6 py-4 font-bold uppercase tracking-wider text-[10px]">ID Produto</th>
<th className="px-6 py-4 font-bold uppercase tracking-wider text-[10px]">Descrição</th> <th className="px-6 py-4 font-bold uppercase tracking-wider text-[10px]">Descrição</th>
<th className="px-6 py-4 font-bold uppercase tracking-wider text-[10px]">Total Vendido</th> <th className="px-6 py-4 font-bold uppercase tracking-wider text-[10px]">Total Vendido</th>
<th className="px-6 py-4 font-bold uppercase tracking-wider text-[10px]">Estoque</th>
<th className="px-6 py-4 font-bold uppercase tracking-wider text-[10px]">Receita Gerada</th> <th className="px-6 py-4 font-bold uppercase tracking-wider text-[10px]">Receita Gerada</th>
<th className="px-6 py-4 font-bold uppercase tracking-wider text-[10px] text-right">Ações</th> <th className="px-6 py-4 font-bold uppercase tracking-wider text-[10px] text-right">Ações</th>
</tr> </tr>
@@ -136,6 +113,11 @@ const Products = () => {
<span className="font-bold text-zinc-900 dark:text-dark-text">{product.totalSold} un.</span> <span className="font-bold text-zinc-900 dark:text-dark-text">{product.totalSold} un.</span>
</div> </div>
</td> </td>
<td className="px-6 py-2.5">
<span className="font-bold text-zinc-900 dark:text-dark-text">
{product.stock} un.
</span>
</td>
<td className="px-6 py-2.5 text-brand-primary font-bold">{formatCurrency(product.revenue)}</td> <td className="px-6 py-2.5 text-brand-primary font-bold">{formatCurrency(product.revenue)}</td>
<td className="px-6 py-2.5 text-right"> <td className="px-6 py-2.5 text-right">
<Link <Link

View File

@@ -8,9 +8,105 @@ export interface OrderData {
Valor_Unitario: number; Valor_Unitario: number;
Recebido_Em?: string; Recebido_Em?: string;
ID_Pedido?: string; ID_Pedido?: string;
Fone_Cliente?: string;
}
export interface StockData {
produto_id: string;
nome: string;
saldo: number;
delta_estoque: number;
updated_at?: string;
} }
export interface DateRange { export interface DateRange {
start: Date; start: Date;
end: Date; 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 {
id: number;
base_product_name: string;
produto_id: string;
nome: string;
saldo: number;
delta_estoque: number;
status: CampaignStatus;
attempts: number;
last_error?: string | null;
created_at: string;
updated_at: string;
sent_at?: string | null;
}
export interface CampaignGroup {
key: string;
baseProductName: string;
status: CampaignStatus;
totalDelta: number;
rowCount: number;
attempts: number;
lastError?: string | null;
createdAt: string;
updatedAt: string;
sentAt?: string | null;
items: CampaignQueueItem[];
}
export interface CampaignQueueSummary {
threshold: number;
maxAttempts: number;
groups: CampaignGroup[];
rows: CampaignQueueItem[];
}
export interface CampaignProductPreview {
baseProduct: string;
total_delta: number;
sizes: Array<{
id: string;
nome: string;
delta: number;
saldo: number;
}>;
}
export interface CampaignPreview {
threshold: number;
readyProducts: CampaignProductPreview[];
belowThresholdProducts: CampaignProductPreview[];
productsText: string;
customerCount: number;
customersPreview: Array<{
nome: string;
fone: string;
total_gasto?: string;
total_comprado?: string;
}>;
}
export interface CampaignProcessSummary {
claimed: number;
sentGroups: number;
skippedGroups: number;
failedGroups: number;
pendingBelowThresholdGroups: number;
}

View File

@@ -1,32 +0,0 @@
[
{
"Nome_Cliente": "Fabrcio Araujo leme",
"Data_Pedido": "13/02/2025",
"Valor_Pedido": 94.95,
"ID_Pedido": "942384335",
"ID_Produto": "942384336",
"Descricao_Produto": "Camiseta Plus Size Premium Algodão Estampada Paris Tira G4 Grafite",
"Quantidade": "1.0000",
"Valor_Unitario": "31.650000"
},
{
"Nome_Cliente": "Fabrcio Araujo leme",
"Data_Pedido": "13/02/2025",
"Valor_Pedido": 94.95,
"ID_Pedido": "942384335",
"ID_Produto": "942384339",
"Descricao_Produto": "Camiseta Plus Size Premium Algodão Estampada Fichas Rolando G4 Preto",
"Quantidade": "1.0000",
"Valor_Unitario": "31.650000"
},
{
"Nome_Cliente": "Fabrcio Araujo leme",
"Data_Pedido": "13/02/2025",
"Valor_Pedido": 94.95,
"ID_Pedido": "942384335",
"ID_Produto": "942384342",
"Descricao_Produto": "Camiseta Plus Size Unissex T-Shirt Premium Sorte Nas Cartas G4 Preto",
"Quantidade": "1.0000",
"Valor_Unitario": "31.650000"
}
]

View File

@@ -3,8 +3,27 @@ import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite' import tailwindcss from '@tailwindcss/vite'
// https://vite.dev/config/ // https://vite.dev/config/
export default defineConfig({ export default defineConfig(({ command }) => ({
plugins: [react(), tailwindcss()], 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: { server: {
port: 3001, port: 3001,
proxy: { proxy: {
@@ -23,4 +42,4 @@ export default defineConfig({
} }
} }
} }
}) }))