Compare commits
24 Commits
560c089639
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0d6ef40c8e | ||
|
|
fce7bbf975 | ||
|
|
a1aa071e1d | ||
|
|
b886b357d7 | ||
|
|
f4cf4366ee | ||
|
|
6dbc5ee190 | ||
|
|
cfdeb03786 | ||
|
|
fd89204973 | ||
|
|
3da299a8af | ||
|
|
e2d0e94080 | ||
|
|
6c0a78675c | ||
|
|
440c8cee1f | ||
|
|
62a0bcfbc9 | ||
|
|
5e0bb1d83a | ||
|
|
72ded82ec7 | ||
|
|
8c2590c56a | ||
|
|
6ba8219596 | ||
|
|
69f99b97c5 | ||
|
|
1f8baabf69 | ||
|
|
c47a64d831 | ||
|
|
4ce1e9aedb | ||
|
|
fc8a5e47a0 | ||
|
|
174bb4841e | ||
|
|
9e52b2e44f |
@@ -13,6 +13,7 @@ POSTGRES_DB=graphdb
|
||||
# --- Application Security ---
|
||||
# The API key used by n8n to authenticate with the backend
|
||||
API_KEY=nexstar_secret_key_123
|
||||
N8N_WHATSAPP_TRIGGER_URL=https://n8n.example.com/webhook/whatsapp-stock
|
||||
|
||||
# --- Dashboard Login Credentials ---
|
||||
ADMIN_EMAIL=admin@admin.com
|
||||
|
||||
99
CONTEXT.md
Normal file
99
CONTEXT.md
Normal 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
169
README.md
@@ -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)
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
|
||||
- Frontend: React, TypeScript, Vite, Tailwind CSS, Recharts
|
||||
- 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
|
||||
|
||||
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...
|
||||
},
|
||||
},
|
||||
])
|
||||
```text
|
||||
n8n -> POST /api/data -> PostgreSQL orders -> dashboard
|
||||
```
|
||||
|
||||
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
|
||||
// eslint.config.js
|
||||
import reactX from 'eslint-plugin-react-x'
|
||||
import reactDom from 'eslint-plugin-react-dom'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
// Enable lint rules for React
|
||||
reactX.configs['recommended-typescript'],
|
||||
// Enable lint rules for React DOM
|
||||
reactDom.configs.recommended,
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```text
|
||||
x-api-key: <API_KEY>
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
### Stock Ingestion
|
||||
|
||||
```text
|
||||
n8n -> POST /api/stock -> PostgreSQL stock + campaign queue
|
||||
```
|
||||
|
||||
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`.
|
||||
|
||||
### Scheduled WhatsApp Campaigns
|
||||
|
||||
```text
|
||||
n8n schedule at 12:00/18:00 BRT
|
||||
-> POST /api/internal/process-stock-campaigns
|
||||
-> backend calls N8N_WHATSAPP_TRIGGER_URL
|
||||
-> n8n WhatsApp workflow sends templates
|
||||
```
|
||||
|
||||
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
40
backend/auth.js
Normal file
@@ -0,0 +1,40 @@
|
||||
const jwt = require('jsonwebtoken');
|
||||
const { ADMIN_EMAIL, ADMIN_PASSWORD, API_KEY, JWT_SECRET } = require('./config');
|
||||
|
||||
const verifyToken = (req, res, next) => {
|
||||
const authHeader = req.headers.authorization;
|
||||
if (!authHeader) return res.status(403).json({ error: 'No token provided' });
|
||||
|
||||
const token = authHeader.split(' ')[1];
|
||||
if (!token) return res.status(403).json({ error: 'Malformed token' });
|
||||
|
||||
jwt.verify(token, JWT_SECRET, (err, decoded) => {
|
||||
if (err) return res.status(401).json({ error: 'Unauthorized' });
|
||||
req.user = decoded;
|
||||
next();
|
||||
});
|
||||
};
|
||||
|
||||
const authenticateAPIKey = (req, res, next) => {
|
||||
const apiKey = req.headers['x-api-key'];
|
||||
if (apiKey === API_KEY) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(401).json({ error: 'Unauthorized: Invalid API Key' });
|
||||
};
|
||||
|
||||
const login = (email, password) => {
|
||||
if (email !== ADMIN_EMAIL || password !== ADMIN_PASSWORD) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return jwt.sign({ email }, JWT_SECRET, { expiresIn: '24h' });
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
verifyToken,
|
||||
authenticateAPIKey,
|
||||
login
|
||||
};
|
||||
11
backend/config.js
Normal file
11
backend/config.js
Normal file
@@ -0,0 +1,11 @@
|
||||
require('dotenv').config();
|
||||
|
||||
module.exports = {
|
||||
PORT: process.env.PORT || 3004,
|
||||
API_KEY: process.env.API_KEY || 'nexstar_secret_key_123',
|
||||
ADMIN_EMAIL: process.env.ADMIN_EMAIL || 'admin@admin.com',
|
||||
ADMIN_PASSWORD: process.env.ADMIN_PASSWORD || 'admin123',
|
||||
JWT_SECRET: process.env.JWT_SECRET || 'super_secret_jwt_key_123',
|
||||
DATABASE_URL: process.env.DATABASE_URL || 'postgres://graphuser:graphpassword@localhost:5432/graphdb',
|
||||
N8N_WHATSAPP_TRIGGER_URL: process.env.N8N_WHATSAPP_TRIGGER_URL || 'http://localhost:5678/webhook/whatsapp'
|
||||
};
|
||||
136
backend/db.js
Normal file
136
backend/db.js
Normal 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
|
||||
};
|
||||
181
backend/index.js
181
backend/index.js
@@ -1,174 +1,19 @@
|
||||
const express = require('express');
|
||||
const cors = require('cors');
|
||||
const bodyParser = require('body-parser');
|
||||
const { Pool } = require('pg');
|
||||
const jwt = require('jsonwebtoken');
|
||||
require('dotenv').config();
|
||||
const { createApp } = require('./server');
|
||||
const { initDB } = require('./db');
|
||||
const { PORT } = require('./config');
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3004;
|
||||
const API_KEY = process.env.API_KEY || "nexstar_secret_key_123";
|
||||
const start = async () => {
|
||||
await initDB();
|
||||
|
||||
// Admin Credentials
|
||||
const ADMIN_EMAIL = process.env.ADMIN_EMAIL || 'admin@admin.com';
|
||||
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || 'admin123';
|
||||
const JWT_SECRET = process.env.JWT_SECRET || 'super_secret_jwt_key_123';
|
||||
|
||||
app.use(cors());
|
||||
app.use(bodyParser.json());
|
||||
|
||||
// PostgreSQL Connection Pool
|
||||
const pool = new Pool({
|
||||
connectionString: process.env.DATABASE_URL || 'postgres://graphuser:graphpassword@localhost:5432/graphdb',
|
||||
});
|
||||
|
||||
// Initialize Database Table
|
||||
const initDB = async () => {
|
||||
try {
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS orders (
|
||||
id SERIAL PRIMARY KEY,
|
||||
cliente_nome VARCHAR(255),
|
||||
data_pedido VARCHAR(50),
|
||||
valor_pedido NUMERIC(10, 2),
|
||||
produto_id VARCHAR(100),
|
||||
produto_descricao TEXT,
|
||||
quantidade INTEGER,
|
||||
valor_unitario NUMERIC(10, 5),
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
`);
|
||||
|
||||
await pool.query(`ALTER TABLE orders ADD COLUMN IF NOT EXISTS pedido_id VARCHAR(100);`).catch(() => {});
|
||||
|
||||
await pool.query(`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();
|
||||
const app = createApp();
|
||||
app.listen(PORT, '0.0.0.0', () => {
|
||||
console.log(`Nexstar Backend running at http://localhost:${PORT}`);
|
||||
console.log(`Endpoint for n8n: POST http://localhost:${PORT}/api/data`);
|
||||
console.log(`Scheduled campaign processor: POST http://localhost:${PORT}/api/internal/process-stock-campaigns`);
|
||||
});
|
||||
};
|
||||
|
||||
// Login Endpoint
|
||||
app.post('/api/login', (req, res) => {
|
||||
const { email, password } = req.body;
|
||||
if (email === ADMIN_EMAIL && password === ADMIN_PASSWORD) {
|
||||
const token = jwt.sign({ email }, JWT_SECRET, { expiresIn: '24h' });
|
||||
res.json({ token });
|
||||
} else {
|
||||
res.status(401).json({ error: 'Invalid credentials' });
|
||||
}
|
||||
start().catch((error) => {
|
||||
console.error('Failed to start backend:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
// Helper to format rows to match the old JSON structure for the frontend
|
||||
const formatRow = (row) => ({
|
||||
Nome_Cliente: row.cliente_nome,
|
||||
Data_Pedido: row.data_pedido,
|
||||
Valor_Pedido: parseFloat(row.valor_pedido),
|
||||
ID_Produto: row.produto_id,
|
||||
Descricao_Produto: row.produto_descricao,
|
||||
Quantidade: row.quantidade,
|
||||
Valor_Unitario: parseFloat(row.valor_unitario),
|
||||
Recebido_Em: row.created_at,
|
||||
ID_Pedido: row.pedido_id
|
||||
});
|
||||
|
||||
// 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`);
|
||||
});
|
||||
62
backend/mappers/orderMapper.js
Normal file
62
backend/mappers/orderMapper.js
Normal 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
|
||||
};
|
||||
31
backend/mappers/stockMapper.js
Normal file
31
backend/mappers/stockMapper.js
Normal 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
|
||||
};
|
||||
@@ -5,7 +5,7 @@
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"start": "node index.js",
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
"test": "node --test"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
|
||||
43
backend/routes/analyticsRoutes.js
Normal file
43
backend/routes/analyticsRoutes.js
Normal file
@@ -0,0 +1,43 @@
|
||||
const express = require('express');
|
||||
const { verifyToken } = require('../auth');
|
||||
const {
|
||||
getClientAnalytics,
|
||||
getDashboardAnalytics,
|
||||
getProductAnalytics
|
||||
} = require('../services/analyticsService');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
const getRange = (query) => ({
|
||||
start: query.start,
|
||||
end: query.end
|
||||
});
|
||||
|
||||
router.get('/analytics/dashboard', verifyToken, async (req, res) => {
|
||||
try {
|
||||
res.json(await getDashboardAnalytics(getRange(req.query)));
|
||||
} catch (error) {
|
||||
console.error('Error fetching dashboard analytics:', error);
|
||||
res.status(500).json({ error: 'Internal Server Error' });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/analytics/products', verifyToken, async (req, res) => {
|
||||
try {
|
||||
res.json(await getProductAnalytics(getRange(req.query)));
|
||||
} catch (error) {
|
||||
console.error('Error fetching product analytics:', error);
|
||||
res.status(500).json({ error: 'Internal Server Error' });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/analytics/clients', verifyToken, async (req, res) => {
|
||||
try {
|
||||
res.json(await getClientAnalytics(getRange(req.query)));
|
||||
} catch (error) {
|
||||
console.error('Error fetching client analytics:', error);
|
||||
res.status(500).json({ error: 'Internal Server Error' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
18
backend/routes/authRoutes.js
Normal file
18
backend/routes/authRoutes.js
Normal file
@@ -0,0 +1,18 @@
|
||||
const express = require('express');
|
||||
const { login } = require('../auth');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.post('/login', (req, res) => {
|
||||
const { email, password } = req.body;
|
||||
const token = login(email, password);
|
||||
|
||||
if (!token) {
|
||||
res.status(401).json({ error: 'Invalid credentials' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({ token });
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
48
backend/routes/campaignRoutes.js
Normal file
48
backend/routes/campaignRoutes.js
Normal 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;
|
||||
26
backend/routes/dataRoutes.js
Normal file
26
backend/routes/dataRoutes.js
Normal file
@@ -0,0 +1,26 @@
|
||||
const express = require('express');
|
||||
const { authenticateAPIKey, verifyToken } = require('../auth');
|
||||
const { listOrders, upsertOrders } = require('../services/ordersService');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.get('/data', verifyToken, async (req, res) => {
|
||||
try {
|
||||
res.json(await listOrders());
|
||||
} catch (error) {
|
||||
console.error('Error fetching data:', error);
|
||||
res.status(500).json({ error: 'Internal Server Error' });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/data', authenticateAPIKey, async (req, res) => {
|
||||
res.status(201).json({ message: 'Data received, processing in background' });
|
||||
|
||||
const payload = Array.isArray(req.body) ? req.body : [req.body];
|
||||
|
||||
upsertOrders(payload).catch((error) => {
|
||||
console.error('Database insert error:', error);
|
||||
});
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
17
backend/routes/internalRoutes.js
Normal file
17
backend/routes/internalRoutes.js
Normal file
@@ -0,0 +1,17 @@
|
||||
const express = require('express');
|
||||
const { authenticateAPIKey } = require('../auth');
|
||||
const { processPendingStockCampaigns } = require('../services/campaignService');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.post('/process-stock-campaigns', authenticateAPIKey, async (req, res) => {
|
||||
try {
|
||||
const summary = await processPendingStockCampaigns();
|
||||
res.json(summary);
|
||||
} catch (error) {
|
||||
console.error('Error processing stock campaigns:', error);
|
||||
res.status(500).json({ error: 'Internal Server Error' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
26
backend/routes/stockRoutes.js
Normal file
26
backend/routes/stockRoutes.js
Normal file
@@ -0,0 +1,26 @@
|
||||
const express = require('express');
|
||||
const { authenticateAPIKey, verifyToken } = require('../auth');
|
||||
const { listStock, upsertStockItems } = require('../services/stockService');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.get('/stock', verifyToken, async (req, res) => {
|
||||
try {
|
||||
res.json(await listStock());
|
||||
} catch (error) {
|
||||
console.error('Error fetching stock:', error);
|
||||
res.status(500).json({ error: 'Internal Server Error' });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/stock', authenticateAPIKey, async (req, res) => {
|
||||
res.status(201).json({ message: 'Stock data received, processing in background' });
|
||||
|
||||
const payload = Array.isArray(req.body) ? req.body : [req.body];
|
||||
|
||||
upsertStockItems(payload).catch((error) => {
|
||||
console.error('Database stock insert error:', error);
|
||||
});
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
29
backend/server.js
Normal file
29
backend/server.js
Normal 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
|
||||
};
|
||||
183
backend/services/analyticsService.js
Normal file
183
backend/services/analyticsService.js
Normal 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
|
||||
};
|
||||
103
backend/services/campaignFormatter.js
Normal file
103
backend/services/campaignFormatter.js
Normal 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
|
||||
};
|
||||
265
backend/services/campaignService.js
Normal file
265
backend/services/campaignService.js
Normal 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
|
||||
};
|
||||
48
backend/services/ordersService.js
Normal file
48
backend/services/ordersService.js
Normal 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
|
||||
};
|
||||
56
backend/services/stockService.js
Normal file
56
backend/services/stockService.js
Normal file
@@ -0,0 +1,56 @@
|
||||
const { pool } = require('../db');
|
||||
const { normalizeStockPayload } = require('../mappers/stockMapper');
|
||||
const { enqueueStockCampaignItem } = require('./campaignService');
|
||||
|
||||
const 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
|
||||
};
|
||||
34
backend/test/analyticsService.test.js
Normal file
34
backend/test/analyticsService.test.js
Normal file
@@ -0,0 +1,34 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const test = require('node:test');
|
||||
|
||||
const { buildDateFilter, normalizeDateParam } = require('../services/analyticsService');
|
||||
|
||||
test('normalizeDateParam accepts strict ISO dates', () => {
|
||||
assert.equal(normalizeDateParam('2026-05-28'), '2026-05-28');
|
||||
});
|
||||
|
||||
test('normalizeDateParam rejects non-ISO or impossible dates', () => {
|
||||
assert.equal(normalizeDateParam('28/05/2026'), null);
|
||||
assert.equal(normalizeDateParam('2026-02-31'), null);
|
||||
assert.equal(normalizeDateParam(''), null);
|
||||
});
|
||||
|
||||
test('buildDateFilter builds bounded date predicates', () => {
|
||||
const filter = buildDateFilter({ start: '2026-05-01', end: '2026-05-28' });
|
||||
|
||||
assert.deepEqual(filter.params, ['2026-05-01', '2026-05-28']);
|
||||
assert.equal(
|
||||
filter.whereClause,
|
||||
'WHERE data_pedido_date IS NOT NULL AND data_pedido_date >= $1::date AND data_pedido_date <= $2::date'
|
||||
);
|
||||
});
|
||||
|
||||
test('buildDateFilter ignores invalid bounds', () => {
|
||||
const filter = buildDateFilter({ start: 'invalid', end: '2026-05-28' });
|
||||
|
||||
assert.deepEqual(filter.params, ['2026-05-28']);
|
||||
assert.equal(
|
||||
filter.whereClause,
|
||||
'WHERE data_pedido_date IS NOT NULL AND data_pedido_date <= $1::date'
|
||||
);
|
||||
});
|
||||
112
backend/test/campaignFormatter.test.js
Normal file
112
backend/test/campaignFormatter.test.js
Normal 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);
|
||||
});
|
||||
37
backend/test/orderMapper.test.js
Normal file
37
backend/test/orderMapper.test.js
Normal file
@@ -0,0 +1,37 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const test = require('node:test');
|
||||
|
||||
const { normalizeOrderDate, normalizeOrderPayload } = require('../mappers/orderMapper');
|
||||
|
||||
test('normalizeOrderDate accepts Brazilian display dates', () => {
|
||||
assert.equal(normalizeOrderDate('28/05/2026'), '2026-05-28');
|
||||
assert.equal(normalizeOrderDate('28-05-2026'), '2026-05-28');
|
||||
});
|
||||
|
||||
test('normalizeOrderDate accepts ISO-like dates', () => {
|
||||
assert.equal(normalizeOrderDate('2026-05-28'), '2026-05-28');
|
||||
assert.equal(normalizeOrderDate('2026/05/28 10:30:00'), '2026-05-28');
|
||||
});
|
||||
|
||||
test('normalizeOrderDate rejects invalid dates', () => {
|
||||
assert.equal(normalizeOrderDate(''), null);
|
||||
assert.equal(normalizeOrderDate('not a date'), null);
|
||||
assert.equal(normalizeOrderDate('31/02/2026'), null);
|
||||
});
|
||||
|
||||
test('normalizeOrderPayload includes normalized date without changing display date', () => {
|
||||
const payload = normalizeOrderPayload({
|
||||
Nome_Cliente: 'Cliente Teste',
|
||||
Data_Pedido: '28/05/2026',
|
||||
Valor_Pedido: '120.50',
|
||||
ID_Produto: 'SKU-1',
|
||||
Descricao_Produto: 'Produto',
|
||||
Quantidade: '2',
|
||||
Valor_Unitario: '60.25',
|
||||
ID_Pedido: 'ORDER-1',
|
||||
Fone_Cliente: '(16) 99999-9999'
|
||||
});
|
||||
|
||||
assert.equal(payload[1], '28/05/2026');
|
||||
assert.equal(payload[2], '2026-05-28');
|
||||
});
|
||||
29
backend/test/stockMapper.test.js
Normal file
29
backend/test/stockMapper.test.js
Normal 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');
|
||||
});
|
||||
@@ -1,5 +1,3 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
db:
|
||||
image: postgres:15-alpine
|
||||
@@ -28,6 +26,7 @@ services:
|
||||
- ADMIN_EMAIL=${ADMIN_EMAIL:-admin@admin.com}
|
||||
- ADMIN_PASSWORD=${ADMIN_PASSWORD:-admin123}
|
||||
- JWT_SECRET=${JWT_SECRET:-super_secret_jwt_key_123}
|
||||
- N8N_WHATSAPP_TRIGGER_URL=${N8N_WHATSAPP_TRIGGER_URL:-http://localhost:5678/webhook/whatsapp}
|
||||
depends_on:
|
||||
- db
|
||||
restart: unless-stopped
|
||||
@@ -44,4 +43,4 @@ services:
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
pgdata:
|
||||
|
||||
@@ -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);
|
||||
42
fake-data.js
42
fake-data.js
@@ -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();
|
||||
184
src/App.css
184
src/App.css
@@ -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);
|
||||
}
|
||||
}
|
||||
48
src/App.tsx
48
src/App.tsx
@@ -1,14 +1,17 @@
|
||||
import React from 'react';
|
||||
import React, { Suspense } from 'react';
|
||||
import { Routes, Route, Navigate, useLocation } from 'react-router-dom';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import Layout from './components/Layout';
|
||||
import Dashboard from './pages/Dashboard';
|
||||
import Products from './pages/Products';
|
||||
import ProductDetails from './pages/ProductDetails';
|
||||
import Clients from './pages/Clients';
|
||||
import ClientDetails from './pages/ClientDetails';
|
||||
import Login from './pages/Login';
|
||||
import { isAuthenticated } from './dataService';
|
||||
|
||||
const Dashboard = React.lazy(() => import('./pages/Dashboard'));
|
||||
const Products = React.lazy(() => import('./pages/Products'));
|
||||
const ProductDetails = React.lazy(() => import('./pages/ProductDetails'));
|
||||
const Clients = React.lazy(() => import('./pages/Clients'));
|
||||
const ClientDetails = React.lazy(() => import('./pages/ClientDetails'));
|
||||
const Campaigns = React.lazy(() => import('./pages/Campaigns'));
|
||||
const Login = React.lazy(() => import('./pages/Login'));
|
||||
|
||||
function PrivateRoute({ children }: { children: React.ReactNode }) {
|
||||
const location = useLocation();
|
||||
if (!isAuthenticated()) {
|
||||
@@ -17,19 +20,28 @@ function PrivateRoute({ children }: { children: React.ReactNode }) {
|
||||
return children;
|
||||
}
|
||||
|
||||
const RouteFallback = () => (
|
||||
<div className="flex min-h-screen items-center justify-center bg-dark-bg text-brand-primary">
|
||||
<Loader2 className="h-8 w-8 animate-spin" />
|
||||
</div>
|
||||
);
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/" element={<Navigate to="/graph" replace />} />
|
||||
<Route path="/" element={<PrivateRoute><Layout /></PrivateRoute>}>
|
||||
<Route path="graph" element={<Dashboard />} />
|
||||
<Route path="products" element={<Products />} />
|
||||
<Route path="products/:id" element={<ProductDetails />} />
|
||||
<Route path="clients" element={<Clients />} />
|
||||
<Route path="clients/:name" element={<ClientDetails />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
<Suspense fallback={<RouteFallback />}>
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/" element={<Navigate to="/graph" replace />} />
|
||||
<Route path="/" element={<PrivateRoute><Layout /></PrivateRoute>}>
|
||||
<Route path="graph" element={<Dashboard />} />
|
||||
<Route path="products" element={<Products />} />
|
||||
<Route path="products/:id" element={<ProductDetails />} />
|
||||
<Route path="clients" element={<Clients />} />
|
||||
<Route path="clients/:name" element={<ClientDetails />} />
|
||||
<Route path="campaigns" element={<Campaigns />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
122
src/analytics/clients.ts
Normal file
122
src/analytics/clients.ts
Normal 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
110
src/analytics/dashboard.ts
Normal 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
30
src/analytics/orders.ts
Normal 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
117
src/analytics/products.ts
Normal 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 |
@@ -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 |
@@ -76,7 +76,7 @@ const DateRangePicker: React.FC<DateRangePickerProps> = ({ dateRange, onChange,
|
||||
} else {
|
||||
ref.current.focus();
|
||||
}
|
||||
} catch (e) {
|
||||
} catch {
|
||||
ref.current.focus();
|
||||
}
|
||||
}
|
||||
@@ -183,4 +183,4 @@ const DateRangePicker: React.FC<DateRangePickerProps> = ({ dateRange, onChange,
|
||||
);
|
||||
};
|
||||
|
||||
export default DateRangePicker;
|
||||
export default DateRangePicker;
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useCallback, useState, useEffect } from 'react';
|
||||
import { Outlet, Link, useLocation } from 'react-router-dom';
|
||||
import { LayoutDashboard, Users, BarChart3, ChevronLeft, ChevronRight, Package, Loader2, LogOut } from 'lucide-react';
|
||||
import type { DateRange, OrderData } from '../types';
|
||||
import { fetchData, logout } from '../dataService';
|
||||
import { LayoutDashboard, Users, BarChart3, ChevronLeft, ChevronRight, Package, Loader2, LogOut, Megaphone } from 'lucide-react';
|
||||
import type { DateRange, OrderData, StockData } from '../types';
|
||||
import { fetchData, fetchStock, logout } from '../dataService';
|
||||
|
||||
const Layout = () => {
|
||||
const location = useLocation();
|
||||
const needsRawData = location.pathname.startsWith('/products') || location.pathname.startsWith('/clients');
|
||||
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(() => {
|
||||
return localStorage.getItem('graph_sidebar_collapsed') === 'true';
|
||||
});
|
||||
@@ -25,32 +26,41 @@ const Layout = () => {
|
||||
});
|
||||
|
||||
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 saved = localStorage.getItem('nexstar_refresh_interval');
|
||||
return saved ? Number(saved) : 0;
|
||||
});
|
||||
|
||||
const loadData = async (showLoading = false) => {
|
||||
const loadData = useCallback(async (showLoading = false) => {
|
||||
if (showLoading) setIsLoading(true);
|
||||
const data = await fetchData();
|
||||
setOrdersData(data);
|
||||
if (showLoading) setIsLoading(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadData(true);
|
||||
try {
|
||||
const [data, stock] = await Promise.all([fetchData(), fetchStock()]);
|
||||
setOrdersData(data);
|
||||
setStockData(stock);
|
||||
} finally {
|
||||
if (showLoading) setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (refreshInterval === 0) return;
|
||||
if (!needsRawData) return;
|
||||
|
||||
// Product and client pages still depend on raw orders until their API migration is complete.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
void loadData(true);
|
||||
}, [loadData, needsRawData]);
|
||||
|
||||
useEffect(() => {
|
||||
if (refreshInterval === 0 || !needsRawData) return;
|
||||
|
||||
const intervalId = setInterval(() => {
|
||||
loadData(false);
|
||||
}, refreshInterval);
|
||||
|
||||
return () => clearInterval(intervalId);
|
||||
}, [refreshInterval]);
|
||||
}, [loadData, needsRawData, refreshInterval]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem('nexstar_refresh_interval', refreshInterval.toString());
|
||||
@@ -73,6 +83,7 @@ const Layout = () => {
|
||||
{ name: 'Dashboard', href: '/graph', icon: LayoutDashboard },
|
||||
{ name: 'Produtos', href: '/products', icon: Package },
|
||||
{ name: 'Clientes', href: '/clients', icon: Users },
|
||||
{ name: 'Campanhas', href: '/campaigns', icon: Megaphone },
|
||||
];
|
||||
|
||||
return (
|
||||
@@ -141,13 +152,13 @@ const Layout = () => {
|
||||
|
||||
{/* Content Area */}
|
||||
<div className="flex-1 overflow-y-auto p-8 relative">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<Loader2 className="w-8 h-8 text-brand-primary animate-spin" />
|
||||
</div>
|
||||
) : (
|
||||
<Outlet context={{ dateRange, setDateRange, ordersData, refreshInterval, setRefreshInterval, loadData }} />
|
||||
{needsRawData && isLoading && (
|
||||
<div className="absolute right-8 top-8 z-10 flex items-center gap-2 rounded-xl border border-dark-border bg-dark-card px-3 py-2 text-sm font-semibold text-dark-muted shadow-sm">
|
||||
<Loader2 className="h-4 w-4 animate-spin text-brand-primary" />
|
||||
Atualizando dados
|
||||
</div>
|
||||
)}
|
||||
<Outlet context={{ dateRange, setDateRange, ordersData, stockData, isDataLoading: needsRawData && isLoading, refreshInterval, setRefreshInterval, loadData }} />
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
146
src/data.json
146
src/data.json
@@ -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
|
||||
}
|
||||
]
|
||||
@@ -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';
|
||||
|
||||
@@ -33,6 +33,25 @@ export const isAuthenticated = (): boolean => {
|
||||
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[]> => {
|
||||
try {
|
||||
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 => {
|
||||
if (!dateStr) return new Date(0);
|
||||
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;
|
||||
};
|
||||
|
||||
export const exportToCSV = (data: any[], filename: string) => {
|
||||
export const exportToCSV = (data: Record<string, unknown>[], filename: string) => {
|
||||
if (!data || !data.length) return;
|
||||
|
||||
const headers = Object.keys(data[0]);
|
||||
@@ -83,7 +192,7 @@ export const exportToCSV = (data: any[], filename: string) => {
|
||||
for (const row of data) {
|
||||
const values = headers.map(header => {
|
||||
const val = row[header];
|
||||
const escaped = ('' + val).replace(/"/g, '\\"');
|
||||
const escaped = String(val ?? '').replace(/"/g, '""');
|
||||
return `"${escaped}"`;
|
||||
});
|
||||
csvRows.push(values.join(','));
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import '@vitejs/plugin-react/preamble'
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { HashRouter } from 'react-router-dom'
|
||||
|
||||
253
src/pages/Campaigns.tsx
Normal file
253
src/pages/Campaigns.tsx
Normal 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;
|
||||
@@ -1,54 +1,30 @@
|
||||
import { useMemo } from 'react';
|
||||
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 { parseOrderDate } from '../dataService';
|
||||
import { buildClientDetailsMetrics } from '../analytics/clients';
|
||||
|
||||
const ClientDetails = () => {
|
||||
const { name } = useParams<{ name: string }>();
|
||||
const decodedName = name ? decodeURIComponent(name) : '';
|
||||
const { ordersData } = useOutletContext<{ ordersData: OrderData[] }>();
|
||||
const { ordersData, isDataLoading } = useOutletContext<{ ordersData: OrderData[], isDataLoading: boolean }>();
|
||||
|
||||
const { groupedOrders, totalSpent, totalItems } = useMemo(() => {
|
||||
const orders = ordersData;
|
||||
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 };
|
||||
const { groupedOrders, totalSpent, totalItems, clientPhone } = useMemo(() => {
|
||||
return buildClientDetailsMetrics(ordersData, decodedName);
|
||||
}, [decodedName, ordersData]);
|
||||
|
||||
const formatCurrency = (value: number) => {
|
||||
return new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(value);
|
||||
};
|
||||
|
||||
if (!groupedOrders.length && isDataLoading) {
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-zinc-500 dark:text-dark-muted font-medium">Carregando cliente...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!groupedOrders.length) {
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
@@ -74,7 +50,18 @@ const ClientDetails = () => {
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-zinc-900 dark:text-dark-text">{decodedName}</h1>
|
||||
<p className="text-zinc-500 dark:text-dark-muted font-medium">Histórico completo de compras</p>
|
||||
<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>
|
||||
{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>
|
||||
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { useMemo, useState, useEffect } from 'react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Link, useOutletContext } from 'react-router-dom';
|
||||
import { Search, ChevronRight, Filter, ChevronLeft, Download } from 'lucide-react';
|
||||
import type { OrderData, DateRange } from '../types';
|
||||
import { parseOrderDate, exportToCSV } from '../dataService';
|
||||
import { exportToCSV } from '../dataService';
|
||||
import DateRangePicker from '../components/DateRangePicker';
|
||||
|
||||
type SortOption = 'recent' | 'spent_desc' | 'spent_asc' | 'items_desc' | 'items_asc';
|
||||
import { buildClientsSummary, type ClientSortOption } from '../analytics/clients';
|
||||
|
||||
const Clients = () => {
|
||||
const { dateRange, setDateRange, ordersData } = useOutletContext<{
|
||||
@@ -14,66 +13,14 @@ const Clients = () => {
|
||||
ordersData: OrderData[]
|
||||
}>();
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [sortBy, setSortBy] = useState<SortOption>('recent');
|
||||
const [sortBy, setSortBy] = useState<ClientSortOption>('recent');
|
||||
|
||||
// Pagination state
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [itemsPerPage, setItemsPerPage] = useState(10);
|
||||
|
||||
// Reset to first page when search, sort, or date changes
|
||||
useEffect(() => {
|
||||
setCurrentPage(1);
|
||||
}, [searchTerm, sortBy, dateRange]);
|
||||
|
||||
const clientsData = useMemo(() => {
|
||||
const orders = ordersData.filter(order => {
|
||||
const orderDate = parseOrderDate(order.Data_Pedido);
|
||||
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;
|
||||
}
|
||||
});
|
||||
return buildClientsSummary(ordersData, dateRange, searchTerm, sortBy);
|
||||
}, [searchTerm, sortBy, ordersData, dateRange]);
|
||||
|
||||
// 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">
|
||||
<DateRangePicker
|
||||
dateRange={dateRange}
|
||||
onChange={setDateRange}
|
||||
onChange={(range) => {
|
||||
setDateRange(range);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="relative">
|
||||
<Filter className="absolute left-3 top-1/2 transform -translate-y-1/2 text-zinc-400 dark:text-dark-muted w-4 h-4" />
|
||||
<select
|
||||
value={sortBy}
|
||||
onChange={(e) => setSortBy(e.target.value as SortOption)}
|
||||
onChange={(e) => {
|
||||
setSortBy(e.target.value as 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"
|
||||
>
|
||||
<option value="recent">Mais Recentes</option>
|
||||
@@ -120,7 +73,10 @@ const Clients = () => {
|
||||
type="text"
|
||||
placeholder="Buscar cliente..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
onChange={(e) => {
|
||||
setSearchTerm(e.target.value);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
className="w-full sm:w-64 bg-dark-card border border-dark-border text-dark-text rounded-xl pl-10 pr-4 py-2.5 focus:outline-none focus:border-brand-primary hover:border-brand-primary transition-colors shadow-sm"
|
||||
/>
|
||||
</div>
|
||||
@@ -129,6 +85,7 @@ const Clients = () => {
|
||||
onClick={() => {
|
||||
const exportData = clientsData.map(client => ({
|
||||
'Nome do Cliente': client.name,
|
||||
'Telefone/WhatsApp': client.phone || 'N/A',
|
||||
'Total Gasto (R$)': client.totalSpent.toFixed(2).replace('.', ','),
|
||||
'Produtos Comprados': client.totalItems,
|
||||
'Total de Pedidos': client.orderCount,
|
||||
|
||||
@@ -1,36 +1,33 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useOutletContext, useNavigate } from 'react-router-dom';
|
||||
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, PieChart, Pie, Cell } from 'recharts';
|
||||
import { DollarSign, ShoppingCart, TrendingUp } from 'lucide-react';
|
||||
import { DollarSign, Loader2, ShoppingCart, TrendingUp } from 'lucide-react';
|
||||
import DateRangePicker from '../components/DateRangePicker';
|
||||
import type { OrderData, DateRange } from '../types';
|
||||
import { parseOrderDate } 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];
|
||||
};
|
||||
import type { DashboardAnalytics, OrderData, DateRange } from '../types';
|
||||
import { applyDashboardColors, buildDashboardMetrics } from '../analytics/dashboard';
|
||||
import { fetchDashboardAnalytics } from '../dataService';
|
||||
|
||||
const formatCurrency = (value: number) => {
|
||||
return new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(value);
|
||||
};
|
||||
|
||||
const CustomTooltip = ({ active, payload, label, isCurrency }: any) => {
|
||||
type ChartTooltipPayload = {
|
||||
value: number;
|
||||
name?: string;
|
||||
color?: string;
|
||||
payload?: {
|
||||
fill?: string;
|
||||
};
|
||||
};
|
||||
|
||||
type CustomTooltipProps = {
|
||||
active?: boolean;
|
||||
payload?: ChartTooltipPayload[];
|
||||
label?: string;
|
||||
isCurrency?: boolean;
|
||||
};
|
||||
|
||||
const CustomTooltip = ({ active, payload, label, isCurrency }: CustomTooltipProps) => {
|
||||
if (active && payload && payload.length) {
|
||||
const color = payload[0].payload?.fill || payload[0].color || '#9ECAE1';
|
||||
const displayLabel = label || payload[0].name;
|
||||
@@ -48,74 +45,47 @@ const CustomTooltip = ({ active, payload, label, isCurrency }: any) => {
|
||||
|
||||
const Dashboard = () => {
|
||||
const navigate = useNavigate();
|
||||
const { dateRange, setDateRange, ordersData, refreshInterval, setRefreshInterval, loadData } = useOutletContext<{
|
||||
const { dateRange, setDateRange, ordersData, refreshInterval, setRefreshInterval } = useOutletContext<{
|
||||
dateRange: DateRange,
|
||||
setDateRange: (range: DateRange) => void,
|
||||
ordersData: OrderData[],
|
||||
refreshInterval: number,
|
||||
setRefreshInterval: (interval: number) => void,
|
||||
loadData: (showLoading?: boolean) => void
|
||||
}>();
|
||||
const [serverMetrics, setServerMetrics] = useState<DashboardAnalytics | null>(null);
|
||||
const [isMetricsLoading, setIsMetricsLoading] = useState(true);
|
||||
|
||||
const filteredData = useMemo(() => {
|
||||
const orders = ordersData;
|
||||
return orders.filter(order => {
|
||||
const orderDate = parseOrderDate(order.Data_Pedido);
|
||||
return orderDate >= dateRange.start && orderDate <= dateRange.end;
|
||||
});
|
||||
}, [dateRange, ordersData]);
|
||||
const loadDashboardMetrics = useCallback(async (range: DateRange) => {
|
||||
setIsMetricsLoading(true);
|
||||
const metrics = await fetchDashboardAnalytics(range);
|
||||
setServerMetrics(metrics);
|
||||
setIsMetricsLoading(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// Dashboard metrics are synchronized with the selected server-side date range.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
void loadDashboardMetrics(dateRange);
|
||||
}, [dateRange, loadDashboardMetrics]);
|
||||
|
||||
useEffect(() => {
|
||||
if (refreshInterval === 0) return;
|
||||
|
||||
const intervalId = setInterval(() => {
|
||||
void loadDashboardMetrics(dateRange);
|
||||
}, refreshInterval);
|
||||
|
||||
return () => clearInterval(intervalId);
|
||||
}, [dateRange, loadDashboardMetrics, refreshInterval]);
|
||||
|
||||
const { totalRevenue, totalOrders, averageOrderValue, salesByProduct, revenueByProduct } = useMemo(() => {
|
||||
let revenue = 0;
|
||||
let totalItems = 0;
|
||||
const productSalesMap: Record<string, number> = {};
|
||||
const productRevenueMap: Record<string, number> = {};
|
||||
const productNameIdMap: Record<string, string> = {};
|
||||
if (serverMetrics) return applyDashboardColors(serverMetrics);
|
||||
return buildDashboardMetrics(ordersData, dateRange);
|
||||
}, [dateRange, ordersData, serverMetrics]);
|
||||
|
||||
filteredData.forEach(order => {
|
||||
const itemRevenue = order.Quantidade * order.Valor_Unitario;
|
||||
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]);
|
||||
const handleManualRefresh = () => {
|
||||
void loadDashboardMetrics(dateRange);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -129,10 +99,17 @@ const Dashboard = () => {
|
||||
onChange={setDateRange}
|
||||
refreshInterval={refreshInterval}
|
||||
setRefreshInterval={setRefreshInterval}
|
||||
onManualRefresh={() => loadData(true)}
|
||||
onManualRefresh={handleManualRefresh}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isMetricsLoading && (
|
||||
<div className="inline-flex items-center gap-2 rounded-xl border border-dark-border bg-dark-card px-3 py-2 text-sm font-semibold text-dark-muted">
|
||||
<Loader2 className="h-4 w-4 animate-spin text-brand-primary" />
|
||||
Atualizando indicadores
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div className="bg-dark-card p-6 rounded-2xl border border-dark-border shadow-sm">
|
||||
<div className="flex justify-between items-start">
|
||||
|
||||
@@ -22,7 +22,7 @@ const Login = () => {
|
||||
} else {
|
||||
setError('E-mail ou senha incorretos.');
|
||||
}
|
||||
} catch (err) {
|
||||
} catch {
|
||||
setError('Erro ao conectar ao servidor.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
|
||||
@@ -4,9 +4,15 @@ import { ArrowLeft, Package, DollarSign } from 'lucide-react';
|
||||
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
|
||||
import DateRangePicker from '../components/DateRangePicker';
|
||||
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) {
|
||||
return (
|
||||
<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 { id } = useParams<{ id: string }>();
|
||||
const { dateRange, setDateRange, ordersData } = useOutletContext<{
|
||||
const { dateRange, setDateRange, ordersData, isDataLoading } = useOutletContext<{
|
||||
dateRange: DateRange,
|
||||
setDateRange: (range: DateRange) => void,
|
||||
ordersData: OrderData[],
|
||||
isDataLoading: boolean,
|
||||
refreshInterval: number,
|
||||
setRefreshInterval: (interval: number) => void,
|
||||
loadData: (showLoading?: boolean) => void
|
||||
}>();
|
||||
|
||||
const { productInfo, chartData, totalSold, totalRevenue } = useMemo(() => {
|
||||
const orders = ordersData.filter(order => order.ID_Produto === id);
|
||||
|
||||
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 };
|
||||
return buildProductDetailsMetrics(ordersData, id, dateRange);
|
||||
}, [id, dateRange, ordersData]);
|
||||
|
||||
const formatCurrency = (value: number) => {
|
||||
return new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(value);
|
||||
};
|
||||
|
||||
if (!productInfo && isDataLoading) {
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-zinc-500 dark:text-dark-muted font-medium">Carregando produto...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!productInfo) {
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import { useMemo, useState, useEffect } from 'react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Link, useOutletContext } from 'react-router-dom';
|
||||
import { Search, Package, TrendingUp, ChevronLeft, ChevronRight, Download } from 'lucide-react';
|
||||
import DateRangePicker from '../components/DateRangePicker';
|
||||
import type { OrderData, DateRange } from '../types';
|
||||
import { parseOrderDate, exportToCSV } from '../dataService';
|
||||
import type { OrderData, DateRange, StockData } from '../types';
|
||||
import { exportToCSV } from '../dataService';
|
||||
import { buildProductsSummary } from '../analytics/products';
|
||||
|
||||
const Products = () => {
|
||||
const { dateRange, setDateRange, ordersData } = useOutletContext<{
|
||||
const { dateRange, setDateRange, ordersData, stockData } = useOutletContext<{
|
||||
dateRange: DateRange,
|
||||
setDateRange: (range: DateRange) => void,
|
||||
ordersData: OrderData[],
|
||||
stockData: StockData[],
|
||||
refreshInterval: number,
|
||||
setRefreshInterval: (interval: number) => void,
|
||||
loadData: (showLoading?: boolean) => void
|
||||
@@ -20,41 +22,9 @@ const Products = () => {
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [itemsPerPage, setItemsPerPage] = useState(10);
|
||||
|
||||
// Reset to first page when search or date range changes
|
||||
useEffect(() => {
|
||||
setCurrentPage(1);
|
||||
}, [searchTerm, dateRange]);
|
||||
|
||||
const productsData = useMemo(() => {
|
||||
const orders = ordersData;
|
||||
const productMap: Record<string, { id: string, name: string, totalSold: number, revenue: number, lastPrice: number }> = {};
|
||||
|
||||
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]);
|
||||
return buildProductsSummary(ordersData, stockData, dateRange, searchTerm);
|
||||
}, [dateRange, searchTerm, ordersData, stockData]);
|
||||
|
||||
// Pagination logic
|
||||
const totalPages = Math.ceil(productsData.length / itemsPerPage);
|
||||
@@ -76,7 +46,10 @@ const Products = () => {
|
||||
<div className="flex flex-col sm:flex-row gap-3">
|
||||
<DateRangePicker
|
||||
dateRange={dateRange}
|
||||
onChange={setDateRange}
|
||||
onChange={(range) => {
|
||||
setDateRange(range);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="relative">
|
||||
@@ -85,7 +58,10 @@ const Products = () => {
|
||||
type="text"
|
||||
placeholder="Buscar por nome ou ID..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
onChange={(e) => {
|
||||
setSearchTerm(e.target.value);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
className="w-full md:w-64 bg-dark-card border border-dark-border text-dark-text rounded-xl pl-10 pr-4 py-2.5 focus:outline-none focus:border-brand-primary hover:border-brand-primary transition-colors shadow-sm"
|
||||
/>
|
||||
</div>
|
||||
@@ -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]">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]">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] text-right">Ações</th>
|
||||
</tr>
|
||||
@@ -136,6 +113,11 @@ const Products = () => {
|
||||
<span className="font-bold text-zinc-900 dark:text-dark-text">{product.totalSold} un.</span>
|
||||
</div>
|
||||
</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-right">
|
||||
<Link
|
||||
|
||||
96
src/types.ts
96
src/types.ts
@@ -8,9 +8,105 @@ export interface OrderData {
|
||||
Valor_Unitario: number;
|
||||
Recebido_Em?: 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 {
|
||||
start: 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;
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
@@ -3,8 +3,27 @@ import react from '@vitejs/plugin-react'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss()],
|
||||
export default defineConfig(({ command }) => ({
|
||||
plugins: [
|
||||
command === 'serve' && {
|
||||
name: 'react-refresh-preamble-fix',
|
||||
transformIndexHtml() {
|
||||
return [{
|
||||
tag: 'script',
|
||||
attrs: { type: 'module' },
|
||||
children: `
|
||||
import RefreshRuntime from "/@react-refresh";
|
||||
RefreshRuntime.injectIntoGlobalHook(window);
|
||||
window.$RefreshReg$ = () => {};
|
||||
window.$RefreshSig$ = () => (type) => type;
|
||||
window.__vite_plugin_react_preamble_installed__ = true;
|
||||
`
|
||||
}]
|
||||
}
|
||||
},
|
||||
react(),
|
||||
tailwindcss()
|
||||
],
|
||||
server: {
|
||||
port: 3001,
|
||||
proxy: {
|
||||
@@ -23,4 +42,4 @@ export default defineConfig({
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
Reference in New Issue
Block a user