Compare commits
59 Commits
7a291120c7
...
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 | ||
|
|
c77da0a9d0 | ||
|
|
ceecbc354d | ||
|
|
2d85d2dcd5 | ||
|
|
985d182743 | ||
|
|
6aa9fff34c | ||
|
|
fbd35d65af | ||
|
|
8be7dbbe06 | ||
|
|
d4fad9f6c1 | ||
|
|
f6d4be1afb | ||
|
|
e66a90d583 | ||
|
|
7959e18210 | ||
|
|
802558510f | ||
|
|
df5f60e540 | ||
|
|
b048c963dd | ||
|
|
b986eafb98 | ||
|
|
3bb46cff1a | ||
|
|
d3167dbac1 | ||
|
|
44028d3b41 | ||
|
|
4324e8e078 | ||
|
|
e7f39a1e35 | ||
|
|
9e20dc997a | ||
|
|
a7bdd07c09 | ||
|
|
c15de19180 | ||
|
|
8f17f7b4fd | ||
|
|
8eb5d34247 | ||
|
|
16962c89ee | ||
|
|
c409276698 | ||
|
|
2c1e75593c | ||
|
|
6c2ed8c301 | ||
|
|
1d45dd3649 | ||
|
|
cf3f79b3da | ||
|
|
41a1afc0e5 | ||
|
|
00942fd9b1 | ||
|
|
940b2113cc | ||
|
|
4ffe97ede8 |
@@ -13,6 +13,7 @@ POSTGRES_DB=graphdb
|
|||||||
# --- Application Security ---
|
# --- Application Security ---
|
||||||
# The API key used by n8n to authenticate with the backend
|
# The API key used by n8n to authenticate with the backend
|
||||||
API_KEY=nexstar_secret_key_123
|
API_KEY=nexstar_secret_key_123
|
||||||
|
N8N_WHATSAPP_TRIGGER_URL=https://n8n.example.com/webhook/whatsapp-stock
|
||||||
|
|
||||||
# --- Dashboard Login Credentials ---
|
# --- Dashboard Login Credentials ---
|
||||||
ADMIN_EMAIL=admin@admin.com
|
ADMIN_EMAIL=admin@admin.com
|
||||||
|
|||||||
99
CONTEXT.md
Normal file
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)
|
- Frontend: React, TypeScript, Vite, Tailwind CSS, Recharts
|
||||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
|
- Backend: Node.js, Express, PostgreSQL, JWT, API-key webhook auth
|
||||||
|
- Runtime: Docker Compose, Nginx, n8n
|
||||||
|
|
||||||
## React Compiler
|
## Main Flows
|
||||||
|
|
||||||
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
### Sales Ingestion
|
||||||
|
|
||||||
## Expanding the ESLint configuration
|
```text
|
||||||
|
n8n -> POST /api/data -> PostgreSQL orders -> dashboard
|
||||||
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
|
||||||
|
|
||||||
```js
|
|
||||||
export default defineConfig([
|
|
||||||
globalIgnores(['dist']),
|
|
||||||
{
|
|
||||||
files: ['**/*.{ts,tsx}'],
|
|
||||||
extends: [
|
|
||||||
// Other configs...
|
|
||||||
|
|
||||||
// Remove tseslint.configs.recommended and replace with this
|
|
||||||
tseslint.configs.recommendedTypeChecked,
|
|
||||||
// Alternatively, use this for stricter rules
|
|
||||||
tseslint.configs.strictTypeChecked,
|
|
||||||
// Optionally, add this for stylistic rules
|
|
||||||
tseslint.configs.stylisticTypeChecked,
|
|
||||||
|
|
||||||
// Other configs...
|
|
||||||
],
|
|
||||||
languageOptions: {
|
|
||||||
parserOptions: {
|
|
||||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
|
||||||
tsconfigRootDir: import.meta.dirname,
|
|
||||||
},
|
|
||||||
// other options...
|
|
||||||
},
|
|
||||||
},
|
|
||||||
])
|
|
||||||
```
|
```
|
||||||
|
|
||||||
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
The endpoint accepts a single order item or an array. Requests must include:
|
||||||
|
|
||||||
```js
|
```text
|
||||||
// eslint.config.js
|
x-api-key: <API_KEY>
|
||||||
import reactX from 'eslint-plugin-react-x'
|
Content-Type: application/json
|
||||||
import reactDom from 'eslint-plugin-react-dom'
|
```
|
||||||
|
|
||||||
export default defineConfig([
|
### Stock Ingestion
|
||||||
globalIgnores(['dist']),
|
|
||||||
{
|
```text
|
||||||
files: ['**/*.{ts,tsx}'],
|
n8n -> POST /api/stock -> PostgreSQL stock + campaign queue
|
||||||
extends: [
|
```
|
||||||
// Other configs...
|
|
||||||
// Enable lint rules for React
|
Positive stock deltas are queued for WhatsApp campaigns. The scheduled processor groups pending queue rows by base product name and sends a campaign only when the accumulated pending delta reaches at least `100`.
|
||||||
reactX.configs['recommended-typescript'],
|
|
||||||
// Enable lint rules for React DOM
|
### Scheduled WhatsApp Campaigns
|
||||||
reactDom.configs.recommended,
|
|
||||||
],
|
```text
|
||||||
languageOptions: {
|
n8n schedule at 12:00/18:00 BRT
|
||||||
parserOptions: {
|
-> POST /api/internal/process-stock-campaigns
|
||||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
-> backend calls N8N_WHATSAPP_TRIGGER_URL
|
||||||
tsconfigRootDir: import.meta.dirname,
|
-> n8n WhatsApp workflow sends templates
|
||||||
},
|
```
|
||||||
// other options...
|
|
||||||
},
|
The scheduled endpoint is API-key protected and returns a summary:
|
||||||
},
|
|
||||||
])
|
```json
|
||||||
|
{
|
||||||
|
"claimed": 0,
|
||||||
|
"sentGroups": 0,
|
||||||
|
"skippedGroups": 0,
|
||||||
|
"failedGroups": 0,
|
||||||
|
"pendingBelowThresholdGroups": 0
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Local Development
|
||||||
|
|
||||||
|
Start PostgreSQL:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d db
|
||||||
|
```
|
||||||
|
|
||||||
|
Start the backend:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
npm install
|
||||||
|
npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
Start the frontend:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Default local URLs:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Frontend: http://127.0.0.1:3001
|
||||||
|
Backend: http://127.0.0.1:3004
|
||||||
|
```
|
||||||
|
|
||||||
|
Vite may choose a different frontend port if `3001` is already in use.
|
||||||
|
|
||||||
|
## Environment
|
||||||
|
|
||||||
|
Copy `.env.example` and configure production secrets in the runtime environment:
|
||||||
|
|
||||||
|
```text
|
||||||
|
POSTGRES_USER
|
||||||
|
POSTGRES_PASSWORD
|
||||||
|
POSTGRES_DB
|
||||||
|
API_KEY
|
||||||
|
N8N_WHATSAPP_TRIGGER_URL
|
||||||
|
ADMIN_EMAIL
|
||||||
|
ADMIN_PASSWORD
|
||||||
|
JWT_SECRET
|
||||||
|
```
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run lint
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
For backend syntax checks:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
node --check index.js
|
||||||
|
node --check services/campaignService.js
|
||||||
|
node --check services/stockService.js
|
||||||
```
|
```
|
||||||
|
|||||||
40
backend/auth.js
Normal file
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
|
||||||
|
};
|
||||||
164
backend/index.js
164
backend/index.js
@@ -1,157 +1,19 @@
|
|||||||
const express = require('express');
|
const { createApp } = require('./server');
|
||||||
const cors = require('cors');
|
const { initDB } = require('./db');
|
||||||
const bodyParser = require('body-parser');
|
const { PORT } = require('./config');
|
||||||
const { Pool } = require('pg');
|
|
||||||
const jwt = require('jsonwebtoken');
|
|
||||||
require('dotenv').config();
|
|
||||||
|
|
||||||
const app = express();
|
const start = async () => {
|
||||||
const PORT = process.env.PORT || 3004;
|
await initDB();
|
||||||
const API_KEY = process.env.API_KEY || "nexstar_secret_key_123";
|
|
||||||
|
|
||||||
// Admin Credentials
|
const app = createApp();
|
||||||
const ADMIN_EMAIL = process.env.ADMIN_EMAIL || 'admin@admin.com';
|
app.listen(PORT, '0.0.0.0', () => {
|
||||||
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || 'admin123';
|
console.log(`Nexstar Backend running at http://localhost:${PORT}`);
|
||||||
const JWT_SECRET = process.env.JWT_SECRET || 'super_secret_jwt_key_123';
|
console.log(`Endpoint for n8n: POST http://localhost:${PORT}/api/data`);
|
||||||
|
console.log(`Scheduled campaign processor: POST http://localhost:${PORT}/api/internal/process-stock-campaigns`);
|
||||||
app.use(cors());
|
|
||||||
app.use(bodyParser.json());
|
|
||||||
|
|
||||||
// PostgreSQL Connection Pool
|
|
||||||
const pool = new Pool({
|
|
||||||
connectionString: process.env.DATABASE_URL || 'postgres://graphuser:graphpassword@localhost:5432/graphdb',
|
|
||||||
});
|
|
||||||
|
|
||||||
// Initialize Database Table
|
|
||||||
const initDB = async () => {
|
|
||||||
try {
|
|
||||||
await pool.query(`
|
|
||||||
CREATE TABLE IF NOT EXISTS orders (
|
|
||||||
id SERIAL PRIMARY KEY,
|
|
||||||
cliente_nome VARCHAR(255),
|
|
||||||
data_pedido VARCHAR(50),
|
|
||||||
valor_pedido NUMERIC(10, 2),
|
|
||||||
produto_id VARCHAR(100),
|
|
||||||
produto_descricao TEXT,
|
|
||||||
quantidade INTEGER,
|
|
||||||
valor_unitario NUMERIC(10, 5),
|
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
||||||
);
|
|
||||||
`);
|
|
||||||
console.log("Database initialized successfully.");
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Failed to initialize database:", err);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
initDB();
|
|
||||||
|
|
||||||
// Middleware for Frontend Authentication
|
|
||||||
const verifyToken = (req, res, next) => {
|
|
||||||
const authHeader = req.headers['authorization'];
|
|
||||||
if (!authHeader) return res.status(403).json({ error: 'No token provided' });
|
|
||||||
|
|
||||||
const token = authHeader.split(' ')[1];
|
|
||||||
if (!token) return res.status(403).json({ error: 'Malformed token' });
|
|
||||||
|
|
||||||
jwt.verify(token, JWT_SECRET, (err, decoded) => {
|
|
||||||
if (err) return res.status(401).json({ error: 'Unauthorized' });
|
|
||||||
req.user = decoded;
|
|
||||||
next();
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// Login Endpoint
|
start().catch((error) => {
|
||||||
app.post('/api/login', (req, res) => {
|
console.error('Failed to start backend:', error);
|
||||||
const { email, password } = req.body;
|
process.exit(1);
|
||||||
if (email === ADMIN_EMAIL && password === ADMIN_PASSWORD) {
|
|
||||||
const token = jwt.sign({ email }, JWT_SECRET, { expiresIn: '24h' });
|
|
||||||
res.json({ token });
|
|
||||||
} else {
|
|
||||||
res.status(401).json({ error: 'Invalid credentials' });
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Helper to format rows to match the old JSON structure for the frontend
|
|
||||||
const formatRow = (row) => ({
|
|
||||||
Nome_Cliente: row.cliente_nome,
|
|
||||||
Data_Pedido: row.data_pedido,
|
|
||||||
Valor_Pedido: parseFloat(row.valor_pedido),
|
|
||||||
ID_Produto: row.produto_id,
|
|
||||||
Descricao_Produto: row.produto_descricao,
|
|
||||||
Quantidade: row.quantidade,
|
|
||||||
Valor_Unitario: parseFloat(row.valor_unitario)
|
|
||||||
});
|
|
||||||
|
|
||||||
// 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 ASC');
|
|
||||||
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
|
|
||||||
) VALUES ($1, $2, $3, $4, $5, $6, $7)
|
|
||||||
`;
|
|
||||||
|
|
||||||
for (const item of payload) {
|
|
||||||
// Handle potential missing fields gracefully
|
|
||||||
const values = [
|
|
||||||
item.Nome_Cliente || 'Unknown',
|
|
||||||
item.Data_Pedido || '',
|
|
||||||
item.Valor_Pedido || 0,
|
|
||||||
item.ID_Produto || '',
|
|
||||||
item.Descricao_Produto || '',
|
|
||||||
item.Quantidade || 0,
|
|
||||||
item.Valor_Unitario || 0
|
|
||||||
];
|
|
||||||
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`);
|
|
||||||
});
|
|
||||||
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",
|
"main": "index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node index.js",
|
"start": "node index.js",
|
||||||
"test": "echo \"Error: no test specified\" && exit 1"
|
"test": "node --test"
|
||||||
},
|
},
|
||||||
"keywords": [],
|
"keywords": [],
|
||||||
"author": "",
|
"author": "",
|
||||||
|
|||||||
43
backend/routes/analyticsRoutes.js
Normal file
43
backend/routes/analyticsRoutes.js
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const { verifyToken } = require('../auth');
|
||||||
|
const {
|
||||||
|
getClientAnalytics,
|
||||||
|
getDashboardAnalytics,
|
||||||
|
getProductAnalytics
|
||||||
|
} = require('../services/analyticsService');
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
const getRange = (query) => ({
|
||||||
|
start: query.start,
|
||||||
|
end: query.end
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/analytics/dashboard', verifyToken, async (req, res) => {
|
||||||
|
try {
|
||||||
|
res.json(await getDashboardAnalytics(getRange(req.query)));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching dashboard analytics:', error);
|
||||||
|
res.status(500).json({ error: 'Internal Server Error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/analytics/products', verifyToken, async (req, res) => {
|
||||||
|
try {
|
||||||
|
res.json(await getProductAnalytics(getRange(req.query)));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching product analytics:', error);
|
||||||
|
res.status(500).json({ error: 'Internal Server Error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/analytics/clients', verifyToken, async (req, res) => {
|
||||||
|
try {
|
||||||
|
res.json(await getClientAnalytics(getRange(req.query)));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching client analytics:', error);
|
||||||
|
res.status(500).json({ error: 'Internal Server Error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
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:
|
services:
|
||||||
db:
|
db:
|
||||||
image: postgres:15-alpine
|
image: postgres:15-alpine
|
||||||
@@ -28,6 +26,7 @@ services:
|
|||||||
- ADMIN_EMAIL=${ADMIN_EMAIL:-admin@admin.com}
|
- ADMIN_EMAIL=${ADMIN_EMAIL:-admin@admin.com}
|
||||||
- ADMIN_PASSWORD=${ADMIN_PASSWORD:-admin123}
|
- ADMIN_PASSWORD=${ADMIN_PASSWORD:-admin123}
|
||||||
- JWT_SECRET=${JWT_SECRET:-super_secret_jwt_key_123}
|
- JWT_SECRET=${JWT_SECRET:-super_secret_jwt_key_123}
|
||||||
|
- N8N_WHATSAPP_TRIGGER_URL=${N8N_WHATSAPP_TRIGGER_URL:-http://localhost:5678/webhook/whatsapp}
|
||||||
depends_on:
|
depends_on:
|
||||||
- db
|
- db
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
@@ -44,4 +43,4 @@ services:
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
pgdata:
|
pgdata:
|
||||||
|
|||||||
10
nginx.conf
10
nginx.conf
@@ -11,9 +11,13 @@ server {
|
|||||||
location /api/ {
|
location /api/ {
|
||||||
proxy_pass http://backend:3004/api/;
|
proxy_pass http://backend:3004/api/;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Upgrade $http_upgrade;
|
|
||||||
proxy_set_header Connection 'upgrade';
|
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_cache_bypass $http_upgrade;
|
|
||||||
|
# SSE specific settings
|
||||||
|
proxy_buffering off;
|
||||||
|
proxy_cache off;
|
||||||
|
proxy_set_header Connection '';
|
||||||
|
chunked_transfer_encoding off;
|
||||||
|
proxy_read_timeout 24h;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
49
src/App.tsx
49
src/App.tsx
@@ -1,14 +1,18 @@
|
|||||||
|
import React, { Suspense } from 'react';
|
||||||
import { Routes, Route, Navigate, useLocation } from 'react-router-dom';
|
import { Routes, Route, Navigate, useLocation } from 'react-router-dom';
|
||||||
|
import { Loader2 } from 'lucide-react';
|
||||||
import Layout from './components/Layout';
|
import Layout from './components/Layout';
|
||||||
import Dashboard from './pages/Dashboard';
|
|
||||||
import Products from './pages/Products';
|
|
||||||
import ProductDetails from './pages/ProductDetails';
|
|
||||||
import Clients from './pages/Clients';
|
|
||||||
import ClientDetails from './pages/ClientDetails';
|
|
||||||
import Login from './pages/Login';
|
|
||||||
import { isAuthenticated } from './dataService';
|
import { isAuthenticated } from './dataService';
|
||||||
|
|
||||||
function PrivateRoute({ children }: { children: JSX.Element }) {
|
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();
|
const location = useLocation();
|
||||||
if (!isAuthenticated()) {
|
if (!isAuthenticated()) {
|
||||||
return <Navigate to="/login" state={{ from: location }} replace />;
|
return <Navigate to="/login" state={{ from: location }} replace />;
|
||||||
@@ -16,19 +20,28 @@ function PrivateRoute({ children }: { children: JSX.Element }) {
|
|||||||
return children;
|
return children;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const RouteFallback = () => (
|
||||||
|
<div className="flex min-h-screen items-center justify-center bg-dark-bg text-brand-primary">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
<Routes>
|
<Suspense fallback={<RouteFallback />}>
|
||||||
<Route path="/login" element={<Login />} />
|
<Routes>
|
||||||
<Route path="/" element={<Navigate to="/graph" replace />} />
|
<Route path="/login" element={<Login />} />
|
||||||
<Route path="/" element={<PrivateRoute><Layout /></PrivateRoute>}>
|
<Route path="/" element={<Navigate to="/graph" replace />} />
|
||||||
<Route path="graph" element={<Dashboard />} />
|
<Route path="/" element={<PrivateRoute><Layout /></PrivateRoute>}>
|
||||||
<Route path="products" element={<Products />} />
|
<Route path="graph" element={<Dashboard />} />
|
||||||
<Route path="products/:id" element={<ProductDetails />} />
|
<Route path="products" element={<Products />} />
|
||||||
<Route path="clients" element={<Clients />} />
|
<Route path="products/:id" element={<ProductDetails />} />
|
||||||
<Route path="clients/:name" element={<ClientDetails />} />
|
<Route path="clients" element={<Clients />} />
|
||||||
</Route>
|
<Route path="clients/:name" element={<ClientDetails />} />
|
||||||
</Routes>
|
<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 |
@@ -1,16 +1,45 @@
|
|||||||
import React, { useRef } from 'react';
|
import React, { useState, useRef } from 'react';
|
||||||
import { Calendar } from 'lucide-react';
|
import { Calendar, RefreshCw, ChevronDown } from 'lucide-react';
|
||||||
import type { DateRange } from '../types';
|
import type { DateRange } from '../types';
|
||||||
|
|
||||||
interface DateRangePickerProps {
|
interface DateRangePickerProps {
|
||||||
dateRange: DateRange;
|
dateRange: DateRange;
|
||||||
onChange: (range: DateRange) => void;
|
onChange: (range: DateRange) => void;
|
||||||
|
refreshInterval?: number;
|
||||||
|
setRefreshInterval?: (interval: number) => void;
|
||||||
|
onManualRefresh?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DateRangePicker: React.FC<DateRangePickerProps> = ({ dateRange, onChange }) => {
|
const PRESETS = [
|
||||||
|
{ label: 'Hoje', getRange: () => { const d = new Date(); d.setHours(0,0,0,0); return { start: d, end: new Date() }; } },
|
||||||
|
{ label: 'Ontem', getRange: () => { const d = new Date(); d.setDate(d.getDate() - 1); d.setHours(0,0,0,0); const end = new Date(d); end.setHours(23,59,59,999); return { start: d, end }; } },
|
||||||
|
{ label: 'Últimos 7 dias', getRange: () => { const end = new Date(); const start = new Date(); start.setDate(start.getDate() - 7); start.setHours(0,0,0,0); return { start, end }; } },
|
||||||
|
{ label: 'Últimos 30 dias', getRange: () => { const end = new Date(); const start = new Date(); start.setDate(start.getDate() - 30); start.setHours(0,0,0,0); return { start, end }; } },
|
||||||
|
{ label: 'Este Mês', getRange: () => { const end = new Date(); const start = new Date(end.getFullYear(), end.getMonth(), 1); return { start, end }; } },
|
||||||
|
{ label: 'Mês Passado', getRange: () => { const d = new Date(); const start = new Date(d.getFullYear(), d.getMonth() - 1, 1); const end = new Date(d.getFullYear(), d.getMonth(), 0, 23, 59, 59, 999); return { start, end }; } },
|
||||||
|
{ label: 'Últimos 90 dias', getRange: () => { const end = new Date(); const start = new Date(); start.setDate(start.getDate() - 90); start.setHours(0,0,0,0); return { start, end }; } },
|
||||||
|
{ label: 'Este Ano', getRange: () => { const end = new Date(); const start = new Date(end.getFullYear(), 0, 1); return { start, end }; } },
|
||||||
|
{ label: 'Todo o Período', getRange: () => { const end = new Date(); const start = new Date(2000, 0, 1); return { start, end }; } },
|
||||||
|
];
|
||||||
|
|
||||||
|
const REFRESH_OPTIONS = [
|
||||||
|
{ label: 'Desligado', value: 0 },
|
||||||
|
{ label: '5s', value: 5000 },
|
||||||
|
{ label: '10s', value: 10000 },
|
||||||
|
{ label: '30s', value: 30000 },
|
||||||
|
{ label: '1m', value: 60000 },
|
||||||
|
{ label: '5m', value: 300000 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const DateRangePicker: React.FC<DateRangePickerProps> = ({ dateRange, onChange, refreshInterval, setRefreshInterval, onManualRefresh }) => {
|
||||||
|
const [isPresetOpen, setIsPresetOpen] = useState(false);
|
||||||
const startRef = useRef<HTMLInputElement>(null);
|
const startRef = useRef<HTMLInputElement>(null);
|
||||||
const endRef = useRef<HTMLInputElement>(null);
|
const endRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const formatShortDate = (date: Date) => {
|
||||||
|
return date.toLocaleDateString('pt-BR', { day: '2-digit', month: '2-digit', year: '2-digit' });
|
||||||
|
};
|
||||||
|
|
||||||
const formatDateForInput = (date: Date) => {
|
const formatDateForInput = (date: Date) => {
|
||||||
const year = date.getFullYear();
|
const year = date.getFullYear();
|
||||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||||
@@ -18,10 +47,6 @@ const DateRangePicker: React.FC<DateRangePickerProps> = ({ dateRange, onChange }
|
|||||||
return `${year}-${month}-${day}`;
|
return `${year}-${month}-${day}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatShortDate = (date: Date) => {
|
|
||||||
return date.toLocaleDateString('pt-BR', { day: '2-digit', month: '2-digit', year: '2-digit' });
|
|
||||||
};
|
|
||||||
|
|
||||||
const parseLocalDate = (value: string) => {
|
const parseLocalDate = (value: string) => {
|
||||||
if (!value) return null;
|
if (!value) return null;
|
||||||
const [year, month, day] = value.split('-');
|
const [year, month, day] = value.split('-');
|
||||||
@@ -51,44 +76,109 @@ const DateRangePicker: React.FC<DateRangePickerProps> = ({ dateRange, onChange }
|
|||||||
} else {
|
} else {
|
||||||
ref.current.focus();
|
ref.current.focus();
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch {
|
||||||
ref.current.focus();
|
ref.current.focus();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2 bg-dark-card border border-dark-border px-3 py-2 rounded-xl shadow-sm hover:border-brand-primary transition-colors">
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
<Calendar size={16} className="text-dark-muted shrink-0" />
|
{/* Date Presets Dropdown */}
|
||||||
<div className="flex items-center gap-2 text-sm font-medium text-dark-text">
|
<div className="relative">
|
||||||
<div
|
<button
|
||||||
className="relative cursor-pointer hover:text-brand-primary transition-colors"
|
onClick={() => setIsPresetOpen(!isPresetOpen)}
|
||||||
onClick={() => openPicker(startRef)}
|
className="flex items-center gap-2 bg-dark-card border border-dark-border px-4 py-2.5 rounded-xl shadow-sm hover:border-brand-primary transition-colors text-sm font-medium text-dark-text cursor-pointer"
|
||||||
>
|
>
|
||||||
{formatShortDate(dateRange.start)}
|
<Calendar size={16} className="text-dark-muted shrink-0" />
|
||||||
<input
|
<span>{formatShortDate(dateRange.start)} - {formatShortDate(dateRange.end)}</span>
|
||||||
ref={startRef}
|
<ChevronDown size={14} className="text-dark-muted ml-1" />
|
||||||
type="date"
|
</button>
|
||||||
value={formatDateForInput(dateRange.start)}
|
|
||||||
onChange={handleStartChange}
|
{isPresetOpen && (
|
||||||
className="absolute opacity-0 w-0 h-0 overflow-hidden"
|
<>
|
||||||
/>
|
<div className="fixed inset-0 z-10" onClick={() => setIsPresetOpen(false)}></div>
|
||||||
</div>
|
<div className="absolute right-0 mt-2 w-56 bg-dark-card border border-dark-border rounded-xl shadow-xl z-20 py-2 max-h-[32rem] overflow-y-auto">
|
||||||
<span className="text-dark-muted font-normal text-xs">até</span>
|
<div className="px-4 pb-3 pt-1 border-b border-dark-border mb-2 flex flex-col gap-2">
|
||||||
<div
|
<span className="text-xs font-bold text-dark-muted uppercase tracking-widest">Período Customizado</span>
|
||||||
className="relative cursor-pointer hover:text-brand-primary transition-colors"
|
<div className="flex flex-col gap-1.5">
|
||||||
onClick={() => openPicker(endRef)}
|
<div className="flex items-center justify-between gap-2">
|
||||||
>
|
<span className="text-xs font-medium text-dark-text w-6">De:</span>
|
||||||
{formatShortDate(dateRange.end)}
|
<div
|
||||||
<input
|
className="relative bg-dark-input border border-dark-border text-dark-text text-xs rounded-lg px-2 py-1 focus-within:border-brand-primary w-full cursor-pointer overflow-hidden flex items-center h-6"
|
||||||
ref={endRef}
|
onClick={() => openPicker(startRef)}
|
||||||
type="date"
|
>
|
||||||
value={formatDateForInput(dateRange.end)}
|
<span className="w-full text-center">{formatShortDate(dateRange.start)}</span>
|
||||||
onChange={handleEndChange}
|
<input
|
||||||
className="absolute opacity-0 w-0 h-0 overflow-hidden"
|
ref={startRef}
|
||||||
/>
|
type="date"
|
||||||
</div>
|
value={formatDateForInput(dateRange.start)}
|
||||||
|
onChange={handleStartChange}
|
||||||
|
className="absolute inset-0 opacity-0 cursor-pointer"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<span className="text-xs font-medium text-dark-text w-6">Até:</span>
|
||||||
|
<div
|
||||||
|
className="relative bg-dark-input border border-dark-border text-dark-text text-xs rounded-lg px-2 py-1 focus-within:border-brand-primary w-full cursor-pointer overflow-hidden flex items-center h-6"
|
||||||
|
onClick={() => openPicker(endRef)}
|
||||||
|
>
|
||||||
|
<span className="w-full text-center">{formatShortDate(dateRange.end)}</span>
|
||||||
|
<input
|
||||||
|
ref={endRef}
|
||||||
|
type="date"
|
||||||
|
value={formatDateForInput(dateRange.end)}
|
||||||
|
onChange={handleEndChange}
|
||||||
|
className="absolute inset-0 opacity-0 cursor-pointer"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className="px-4 text-xs font-bold text-dark-muted uppercase tracking-widest mb-1 block">Atalhos</span>
|
||||||
|
{PRESETS.map((preset) => (
|
||||||
|
<button
|
||||||
|
key={preset.label}
|
||||||
|
onClick={() => {
|
||||||
|
onChange(preset.getRange());
|
||||||
|
setIsPresetOpen(false);
|
||||||
|
}}
|
||||||
|
className="w-full text-left px-4 py-2 text-sm text-dark-muted hover:text-dark-text hover:bg-dark-input transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
{preset.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Auto Refresh Dropdown */}
|
||||||
|
{setRefreshInterval && onManualRefresh && (
|
||||||
|
<div className="flex items-center bg-dark-card border border-dark-border rounded-xl shadow-sm overflow-hidden hover:border-brand-primary transition-colors">
|
||||||
|
<button
|
||||||
|
onClick={onManualRefresh}
|
||||||
|
className="flex items-center gap-1.5 px-4 py-2.5 text-sm font-medium text-dark-text hover:text-brand-primary hover:bg-dark-input transition-colors border-r border-dark-border cursor-pointer"
|
||||||
|
title="Atualizar Agora"
|
||||||
|
>
|
||||||
|
<RefreshCw size={16} className="text-brand-primary" />
|
||||||
|
<span>Atualizar</span>
|
||||||
|
</button>
|
||||||
|
<select
|
||||||
|
value={refreshInterval}
|
||||||
|
onChange={(e) => setRefreshInterval(Number(e.target.value))}
|
||||||
|
className="appearance-none bg-transparent text-sm font-bold text-dark-muted pl-4 pr-10 py-2.5 focus:outline-none cursor-pointer relative"
|
||||||
|
style={{ backgroundImage: `url("data:image/svg+xml;charset=UTF-8,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23a1a1aa' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3e%3cpolyline points='6 9 12 15 18 9'%3e%3c/polyline%3e%3c/svg%3e")`, backgroundRepeat: 'no-repeat', backgroundPosition: 'right 0.75rem center', backgroundSize: '1em' }}
|
||||||
|
>
|
||||||
|
{REFRESH_OPTIONS.map(opt => (
|
||||||
|
<option key={opt.label} value={opt.value} className="bg-dark-card font-medium text-dark-text">
|
||||||
|
{opt.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useCallback, useState, useEffect } from 'react';
|
||||||
import { Outlet, Link, useLocation } from 'react-router-dom';
|
import { Outlet, Link, useLocation } from 'react-router-dom';
|
||||||
import { LayoutDashboard, Users, BarChart3, ChevronLeft, ChevronRight, Package, Loader2, LogOut } from 'lucide-react';
|
import { LayoutDashboard, Users, BarChart3, ChevronLeft, ChevronRight, Package, Loader2, LogOut, Megaphone } from 'lucide-react';
|
||||||
import type { DateRange, OrderData } from '../types';
|
import type { DateRange, OrderData, StockData } from '../types';
|
||||||
import { fetchData, logout } from '../dataService';
|
import { fetchData, fetchStock, logout } from '../dataService';
|
||||||
|
|
||||||
const Layout = () => {
|
const Layout = () => {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
const needsRawData = location.pathname.startsWith('/products') || location.pathname.startsWith('/clients');
|
||||||
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(() => {
|
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(() => {
|
||||||
return localStorage.getItem('graph_sidebar_collapsed') === 'true';
|
return localStorage.getItem('graph_sidebar_collapsed') === 'true';
|
||||||
});
|
});
|
||||||
@@ -25,18 +26,45 @@ const Layout = () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const [ordersData, setOrdersData] = useState<OrderData[]>([]);
|
const [ordersData, setOrdersData] = useState<OrderData[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [stockData, setStockData] = useState<StockData[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(needsRawData);
|
||||||
|
const [refreshInterval, setRefreshInterval] = useState<number>(() => {
|
||||||
|
const saved = localStorage.getItem('nexstar_refresh_interval');
|
||||||
|
return saved ? Number(saved) : 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
const loadData = useCallback(async (showLoading = false) => {
|
||||||
|
if (showLoading) setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const [data, stock] = await Promise.all([fetchData(), fetchStock()]);
|
||||||
|
setOrdersData(data);
|
||||||
|
setStockData(stock);
|
||||||
|
} finally {
|
||||||
|
if (showLoading) setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadInitial = async () => {
|
if (!needsRawData) return;
|
||||||
setIsLoading(true);
|
|
||||||
const data = await fetchData();
|
|
||||||
setOrdersData(data);
|
|
||||||
setIsLoading(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
loadInitial();
|
// 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);
|
||||||
|
}, [loadData, needsRawData, refreshInterval]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
localStorage.setItem('nexstar_refresh_interval', refreshInterval.toString());
|
||||||
|
}, [refreshInterval]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
localStorage.setItem('nexstar_date_range', JSON.stringify({
|
localStorage.setItem('nexstar_date_range', JSON.stringify({
|
||||||
@@ -55,6 +83,7 @@ const Layout = () => {
|
|||||||
{ name: 'Dashboard', href: '/graph', icon: LayoutDashboard },
|
{ name: 'Dashboard', href: '/graph', icon: LayoutDashboard },
|
||||||
{ name: 'Produtos', href: '/products', icon: Package },
|
{ name: 'Produtos', href: '/products', icon: Package },
|
||||||
{ name: 'Clientes', href: '/clients', icon: Users },
|
{ name: 'Clientes', href: '/clients', icon: Users },
|
||||||
|
{ name: 'Campanhas', href: '/campaigns', icon: Megaphone },
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -106,7 +135,7 @@ const Layout = () => {
|
|||||||
<div className="p-4 border-t border-dark-border">
|
<div className="p-4 border-t border-dark-border">
|
||||||
<button
|
<button
|
||||||
onClick={logout}
|
onClick={logout}
|
||||||
className={`w-full flex items-center text-red-500 hover:bg-red-500/10 px-4 py-3 rounded-xl transition-all ${isSidebarCollapsed ? 'justify-center' : 'space-x-3'}`}
|
className={`w-full flex items-center text-red-500 hover:bg-red-500/10 px-4 py-3 rounded-xl transition-all cursor-pointer ${isSidebarCollapsed ? 'justify-center' : 'space-x-3'}`}
|
||||||
>
|
>
|
||||||
<LogOut className="w-5 h-5 shrink-0" />
|
<LogOut className="w-5 h-5 shrink-0" />
|
||||||
{!isSidebarCollapsed && <span className="font-medium">Sair</span>}
|
{!isSidebarCollapsed && <span className="font-medium">Sair</span>}
|
||||||
@@ -123,13 +152,13 @@ const Layout = () => {
|
|||||||
|
|
||||||
{/* Content Area */}
|
{/* Content Area */}
|
||||||
<div className="flex-1 overflow-y-auto p-8 relative">
|
<div className="flex-1 overflow-y-auto p-8 relative">
|
||||||
{isLoading ? (
|
{needsRawData && isLoading && (
|
||||||
<div className="flex items-center justify-center h-full">
|
<div className="absolute right-8 top-8 z-10 flex items-center gap-2 rounded-xl border border-dark-border bg-dark-card px-3 py-2 text-sm font-semibold text-dark-muted shadow-sm">
|
||||||
<Loader2 className="w-8 h-8 text-brand-primary animate-spin" />
|
<Loader2 className="h-4 w-4 animate-spin text-brand-primary" />
|
||||||
</div>
|
Atualizando dados
|
||||||
) : (
|
</div>
|
||||||
<Outlet context={{ dateRange, setDateRange, ordersData }} />
|
|
||||||
)}
|
)}
|
||||||
|
<Outlet context={{ dateRange, setDateRange, ordersData, stockData, isDataLoading: needsRawData && isLoading, refreshInterval, setRefreshInterval, loadData }} />
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</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';
|
const API_URL = import.meta.env.VITE_API_URL || '/api';
|
||||||
|
|
||||||
@@ -33,6 +33,25 @@ export const isAuthenticated = (): boolean => {
|
|||||||
return !!localStorage.getItem('auth_token');
|
return !!localStorage.getItem('auth_token');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const fetchStock = async (): Promise<StockData[]> => {
|
||||||
|
try {
|
||||||
|
const token = localStorage.getItem('auth_token');
|
||||||
|
const response = await fetch(`${API_URL}/stock`, {
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${token}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (response.status === 401 || response.status === 403) {
|
||||||
|
logout();
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
if (!response.ok) return [];
|
||||||
|
return await response.json();
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export const fetchData = async (): Promise<OrderData[]> => {
|
export const fetchData = async (): Promise<OrderData[]> => {
|
||||||
try {
|
try {
|
||||||
const token = localStorage.getItem('auth_token');
|
const token = localStorage.getItem('auth_token');
|
||||||
@@ -52,3 +71,143 @@ export const fetchData = async (): Promise<OrderData[]> => {
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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);
|
||||||
|
const parts = dateStr.split(/[-/]/);
|
||||||
|
if (parts.length === 3) {
|
||||||
|
if (parts[0].length === 4) {
|
||||||
|
// YYYY-MM-DD
|
||||||
|
return new Date(Number(parts[0]), Number(parts[1]) - 1, Number(parts[2]));
|
||||||
|
} else {
|
||||||
|
// DD-MM-YYYY
|
||||||
|
return new Date(Number(parts[2]), Number(parts[1]) - 1, Number(parts[0]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const fallback = new Date(dateStr);
|
||||||
|
return isNaN(fallback.getTime()) ? new Date(0) : fallback;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const exportToCSV = (data: Record<string, unknown>[], filename: string) => {
|
||||||
|
if (!data || !data.length) return;
|
||||||
|
|
||||||
|
const headers = Object.keys(data[0]);
|
||||||
|
const csvRows = [];
|
||||||
|
|
||||||
|
// Add headers
|
||||||
|
csvRows.push(headers.join(','));
|
||||||
|
|
||||||
|
// Add rows
|
||||||
|
for (const row of data) {
|
||||||
|
const values = headers.map(header => {
|
||||||
|
const val = row[header];
|
||||||
|
const escaped = String(val ?? '').replace(/"/g, '""');
|
||||||
|
return `"${escaped}"`;
|
||||||
|
});
|
||||||
|
csvRows.push(values.join(','));
|
||||||
|
}
|
||||||
|
|
||||||
|
const csvString = csvRows.join('\n');
|
||||||
|
const blob = new Blob([csvString], { type: 'text/csv;charset=utf-8;' });
|
||||||
|
const link = document.createElement('a');
|
||||||
|
if (link.download !== undefined) {
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
link.setAttribute('href', url);
|
||||||
|
link.setAttribute('download', filename);
|
||||||
|
link.style.visibility = 'hidden';
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import '@vitejs/plugin-react/preamble'
|
||||||
import { StrictMode } from 'react'
|
import { StrictMode } from 'react'
|
||||||
import { createRoot } from 'react-dom/client'
|
import { createRoot } from 'react-dom/client'
|
||||||
import { HashRouter } from 'react-router-dom'
|
import { HashRouter } from 'react-router-dom'
|
||||||
|
|||||||
253
src/pages/Campaigns.tsx
Normal file
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 { useMemo } from 'react';
|
||||||
import { useParams, Link, useOutletContext } from 'react-router-dom';
|
import { useParams, Link, useOutletContext } from 'react-router-dom';
|
||||||
import { ArrowLeft, User, Tag, Package, DollarSign } from 'lucide-react';
|
import { ArrowLeft, User, Tag, Package, DollarSign, Clock, Phone } from 'lucide-react';
|
||||||
import type { OrderData } from '../types';
|
import type { OrderData } from '../types';
|
||||||
|
import { buildClientDetailsMetrics } from '../analytics/clients';
|
||||||
|
|
||||||
const ClientDetails = () => {
|
const ClientDetails = () => {
|
||||||
const { name } = useParams<{ name: string }>();
|
const { name } = useParams<{ name: string }>();
|
||||||
const decodedName = name ? decodeURIComponent(name) : '';
|
const decodedName = name ? decodeURIComponent(name) : '';
|
||||||
const { ordersData } = useOutletContext<{ ordersData: OrderData[] }>();
|
const { ordersData, isDataLoading } = useOutletContext<{ ordersData: OrderData[], isDataLoading: boolean }>();
|
||||||
|
|
||||||
const { groupedOrders, totalSpent, totalItems } = useMemo(() => {
|
const { groupedOrders, totalSpent, totalItems, clientPhone } = useMemo(() => {
|
||||||
const orders = ordersData;
|
return buildClientDetailsMetrics(ordersData, decodedName);
|
||||||
const clientOrders = orders.filter(order => {
|
|
||||||
const clientName = order.Nome_Cliente || `Cliente Desconhecido (Pedido ${order.Valor_Pedido})`;
|
|
||||||
return clientName === decodedName;
|
|
||||||
});
|
|
||||||
|
|
||||||
const groupedOrdersMap: Record<string, { date: string, orderTotal: number, items: OrderData[] }> = {};
|
|
||||||
let totalSpent = 0;
|
|
||||||
let totalItems = 0;
|
|
||||||
|
|
||||||
clientOrders.forEach(order => {
|
|
||||||
totalSpent += (order.Quantidade * order.Valor_Unitario);
|
|
||||||
totalItems += order.Quantidade;
|
|
||||||
|
|
||||||
// Use date and total order value as a unique cart/order identifier
|
|
||||||
const key = `${order.Data_Pedido}_${order.Valor_Pedido}`;
|
|
||||||
if (!groupedOrdersMap[key]) {
|
|
||||||
groupedOrdersMap[key] = {
|
|
||||||
date: order.Data_Pedido,
|
|
||||||
orderTotal: order.Valor_Pedido,
|
|
||||||
items: []
|
|
||||||
};
|
|
||||||
}
|
|
||||||
groupedOrdersMap[key].items.push(order);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Sort grouped orders by date descending
|
|
||||||
const groupedOrders = Object.values(groupedOrdersMap).sort((a, b) => {
|
|
||||||
const [dayA, monthA, yearA] = a.date.split('-').map(Number);
|
|
||||||
const [dayB, monthB, yearB] = b.date.split('-').map(Number);
|
|
||||||
return new Date(yearB, monthB - 1, dayB).getTime() - new Date(yearA, monthA - 1, dayA).getTime();
|
|
||||||
});
|
|
||||||
|
|
||||||
return { groupedOrders, totalSpent, totalItems };
|
|
||||||
}, [decodedName, ordersData]);
|
}, [decodedName, ordersData]);
|
||||||
|
|
||||||
const formatCurrency = (value: number) => {
|
const formatCurrency = (value: number) => {
|
||||||
return new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(value);
|
return new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(value);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (!groupedOrders.length && isDataLoading) {
|
||||||
|
return (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<p className="text-zinc-500 dark:text-dark-muted font-medium">Carregando cliente...</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (!groupedOrders.length) {
|
if (!groupedOrders.length) {
|
||||||
return (
|
return (
|
||||||
<div className="text-center py-12">
|
<div className="text-center py-12">
|
||||||
@@ -74,7 +50,18 @@ const ClientDetails = () => {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-zinc-900 dark:text-dark-text">{decodedName}</h1>
|
<h1 className="text-2xl font-bold text-zinc-900 dark:text-dark-text">{decodedName}</h1>
|
||||||
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -90,16 +77,16 @@ const ClientDetails = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{/* Orders List */}
|
||||||
{/* Orders List */}
|
<div className="flex flex-col gap-6">
|
||||||
<div className="flex flex-col gap-6">
|
{groupedOrders.map((group, groupIndex) => (
|
||||||
{groupedOrders.map((group, groupIndex) => (
|
<div key={groupIndex} className="bg-white dark:bg-dark-card border border-zinc-200 dark:border-dark-border rounded-2xl overflow-hidden shadow-sm">
|
||||||
<div key={groupIndex} className="bg-white dark:bg-dark-card border border-zinc-200 dark:border-dark-border rounded-2xl overflow-hidden shadow-sm">
|
<div className="p-4 border-b border-zinc-100 dark:border-dark-border bg-zinc-50/50 dark:bg-dark-header flex justify-between items-center">
|
||||||
<div className="p-4 border-b border-zinc-100 dark:border-dark-border bg-zinc-50/50 dark:bg-dark-header flex justify-between items-center">
|
<h2 className="text-sm font-bold uppercase tracking-wider text-zinc-500 dark:text-dark-muted flex items-center gap-2">
|
||||||
<h2 className="text-sm font-bold uppercase tracking-wider text-zinc-500 dark:text-dark-muted">
|
<Tag className="w-4 h-4 text-brand-primary" />
|
||||||
Data do Pedido: {group.date}
|
Pedido ID: {group.orderId}
|
||||||
</h2>
|
</h2>
|
||||||
<span className="text-sm font-bold text-brand-primary">
|
<span className="text-sm font-bold text-brand-primary">
|
||||||
Total do Pedido: {formatCurrency(group.orderTotal)}
|
Total do Pedido: {formatCurrency(group.orderTotal)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -113,6 +100,14 @@ const ClientDetails = () => {
|
|||||||
<Tag className="w-3 h-3 text-zinc-400 dark:text-dark-muted" />
|
<Tag className="w-3 h-3 text-zinc-400 dark:text-dark-muted" />
|
||||||
<span className="text-[10px] font-bold text-zinc-400 dark:text-dark-muted">ID: {order.ID_Produto}</span>
|
<span className="text-[10px] font-bold text-zinc-400 dark:text-dark-muted">ID: {order.ID_Produto}</span>
|
||||||
</div>
|
</div>
|
||||||
|
{order.Data_Pedido && (
|
||||||
|
<div className="flex items-center gap-1 ml-2">
|
||||||
|
<Clock className="w-3 h-3 text-zinc-400 dark:text-dark-muted" />
|
||||||
|
<span className="text-[10px] font-bold text-zinc-400 dark:text-dark-muted">
|
||||||
|
Comprado: {order.Data_Pedido}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-sm font-bold text-zinc-900 dark:text-dark-text truncate mb-1">{order.Descricao_Produto}</h3>
|
<h3 className="text-sm font-bold text-zinc-900 dark:text-dark-text truncate mb-1">{order.Descricao_Produto}</h3>
|
||||||
|
|
||||||
|
|||||||
@@ -1,62 +1,32 @@
|
|||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import { Link, useOutletContext } from 'react-router-dom';
|
import { Link, useOutletContext } from 'react-router-dom';
|
||||||
import { Search, ChevronRight, Filter } from 'lucide-react';
|
import { Search, ChevronRight, Filter, ChevronLeft, Download } from 'lucide-react';
|
||||||
import type { OrderData } from '../types';
|
import type { OrderData, DateRange } from '../types';
|
||||||
|
import { exportToCSV } from '../dataService';
|
||||||
type SortOption = 'recent' | 'spent_desc' | 'spent_asc' | 'items_desc' | 'items_asc';
|
import DateRangePicker from '../components/DateRangePicker';
|
||||||
|
import { buildClientsSummary, type ClientSortOption } from '../analytics/clients';
|
||||||
|
|
||||||
const Clients = () => {
|
const Clients = () => {
|
||||||
const { ordersData } = useOutletContext<{ ordersData: OrderData[] }>();
|
const { dateRange, setDateRange, ordersData } = useOutletContext<{
|
||||||
|
dateRange: DateRange,
|
||||||
|
setDateRange: (range: DateRange) => void,
|
||||||
|
ordersData: OrderData[]
|
||||||
|
}>();
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
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);
|
||||||
|
|
||||||
const clientsData = useMemo(() => {
|
const clientsData = useMemo(() => {
|
||||||
const orders = ordersData;
|
return buildClientsSummary(ordersData, dateRange, searchTerm, sortBy);
|
||||||
const clientMap: Record<string, { totalSpent: number, totalItems: number, uniqueOrders: Set<string>, lastPurchase: number }> = {};
|
}, [searchTerm, sortBy, ordersData, dateRange]);
|
||||||
|
|
||||||
orders.forEach(order => {
|
// Pagination logic
|
||||||
const clientName = order.Nome_Cliente || `Cliente Desconhecido (Pedido ${order.Valor_Pedido})`;
|
const totalPages = Math.ceil(clientsData.length / itemsPerPage);
|
||||||
|
const startIndex = (currentPage - 1) * itemsPerPage;
|
||||||
if (!clientMap[clientName]) {
|
const paginatedData = clientsData.slice(startIndex, startIndex + itemsPerPage);
|
||||||
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 [day, month, year] = order.Data_Pedido.split('-').map(Number);
|
|
||||||
const orderTime = new Date(year, month - 1, day).getTime();
|
|
||||||
|
|
||||||
if (orderTime > clientMap[clientName].lastPurchase) {
|
|
||||||
clientMap[clientName].lastPurchase = orderTime;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
let result = Object.keys(clientMap).map(name => ({
|
|
||||||
name,
|
|
||||||
totalSpent: clientMap[name].totalSpent,
|
|
||||||
totalItems: clientMap[name].totalItems,
|
|
||||||
orderCount: clientMap[name].uniqueOrders.size, // Grouped by unique date+value combinations
|
|
||||||
lastPurchase: clientMap[name].lastPurchase
|
|
||||||
}));
|
|
||||||
|
|
||||||
if (searchTerm) {
|
|
||||||
result = result.filter(c => c.name.toLowerCase().includes(searchTerm.toLowerCase()));
|
|
||||||
}
|
|
||||||
|
|
||||||
return result.sort((a, b) => {
|
|
||||||
switch (sortBy) {
|
|
||||||
case 'recent': return b.lastPurchase - a.lastPurchase;
|
|
||||||
case 'spent_desc': return b.totalSpent - a.totalSpent;
|
|
||||||
case 'spent_asc': return a.totalSpent - b.totalSpent;
|
|
||||||
case 'items_desc': return b.totalItems - a.totalItems;
|
|
||||||
case 'items_asc': return a.totalItems - b.totalItems;
|
|
||||||
default: return 0;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}, [searchTerm, sortBy, ordersData]);
|
|
||||||
|
|
||||||
const formatCurrency = (value: number) => {
|
const formatCurrency = (value: number) => {
|
||||||
return new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(value);
|
return new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(value);
|
||||||
@@ -64,19 +34,30 @@ const Clients = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
<div className="flex flex-col xl:flex-row xl:items-center justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold mb-2 text-zinc-900 dark:text-dark-text">Clientes</h1>
|
<h1 className="text-2xl font-bold mb-2 text-zinc-900 dark:text-dark-text">Clientes</h1>
|
||||||
<p className="text-zinc-500 dark:text-dark-muted font-medium">Métricas de engajamento e histórico de consumo dos seus clientes.</p>
|
<p className="text-zinc-500 dark:text-dark-muted font-medium">Métricas de engajamento e histórico de consumo dos seus clientes.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col sm:flex-row gap-3">
|
<div className="flex flex-col sm:flex-row flex-wrap gap-3 items-center justify-start xl:justify-end">
|
||||||
|
<DateRangePicker
|
||||||
|
dateRange={dateRange}
|
||||||
|
onChange={(range) => {
|
||||||
|
setDateRange(range);
|
||||||
|
setCurrentPage(1);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Filter className="absolute left-3 top-1/2 transform -translate-y-1/2 text-zinc-400 dark:text-dark-muted w-4 h-4" />
|
<Filter className="absolute left-3 top-1/2 transform -translate-y-1/2 text-zinc-400 dark:text-dark-muted w-4 h-4" />
|
||||||
<select
|
<select
|
||||||
value={sortBy}
|
value={sortBy}
|
||||||
onChange={(e) => setSortBy(e.target.value as SortOption)}
|
onChange={(e) => {
|
||||||
className="appearance-none bg-white dark:bg-dark-input border border-zinc-200 dark:border-dark-border text-zinc-700 dark:text-dark-text text-sm rounded-xl pl-9 pr-8 py-2.5 focus:outline-none focus:border-brand-primary transition-all shadow-sm cursor-pointer"
|
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>
|
<option value="recent">Mais Recentes</option>
|
||||||
<option value="spent_desc">Maior Gasto</option>
|
<option value="spent_desc">Maior Gasto</option>
|
||||||
@@ -86,16 +67,38 @@ const Clients = () => {
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="relative">
|
<div className="relative w-full sm:w-auto">
|
||||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-zinc-400 dark:text-dark-muted w-5 h-5" />
|
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-zinc-400 dark:text-dark-muted w-5 h-5" />
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Buscar cliente..."
|
placeholder="Buscar cliente..."
|
||||||
value={searchTerm}
|
value={searchTerm}
|
||||||
onChange={(e) => setSearchTerm(e.target.value)}
|
onChange={(e) => {
|
||||||
className="w-full md:w-64 bg-white dark:bg-dark-input border border-zinc-200 dark:border-dark-border text-zinc-900 dark:text-dark-text rounded-xl pl-10 pr-4 py-2.5 focus:outline-none focus:border-brand-primary focus:ring-2 focus:ring-brand-primary/20 transition-all shadow-sm"
|
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>
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
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,
|
||||||
|
'Última Compra': new Date(client.lastPurchase).toLocaleDateString('pt-BR')
|
||||||
|
}));
|
||||||
|
exportToCSV(exportData, `clientes_${new Date().toISOString().split('T')[0]}.csv`);
|
||||||
|
}}
|
||||||
|
className="flex items-center justify-center gap-2 bg-dark-card border border-dark-border px-4 py-2.5 rounded-xl shadow-sm hover:border-brand-primary transition-colors text-sm font-medium text-dark-text cursor-pointer"
|
||||||
|
title="Exportar para CSV"
|
||||||
|
>
|
||||||
|
<Download size={16} className="text-brand-primary" />
|
||||||
|
<span className="hidden sm:inline">Exportar</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -112,11 +115,11 @@ const Clients = () => {
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-zinc-100 dark:divide-dark-border">
|
<tbody className="divide-y divide-zinc-100 dark:divide-dark-border">
|
||||||
{clientsData.map((client, index) => (
|
{paginatedData.map((client, index) => (
|
||||||
<tr key={client.name} className="hover:bg-zinc-50/80 dark:hover:bg-dark-input/50 transition-colors group">
|
<tr key={client.name} className="hover:bg-zinc-50/80 dark:hover:bg-dark-input/50 transition-colors group">
|
||||||
<td className="px-6 py-2.5">
|
<td className="px-6 py-2.5">
|
||||||
<span className="inline-flex items-center justify-center w-7 h-7 rounded-full text-xs font-bold bg-zinc-100 dark:bg-dark-border text-zinc-500 dark:text-dark-muted">
|
<span className="inline-flex items-center justify-center w-7 h-7 rounded-full text-xs font-bold bg-zinc-100 dark:bg-dark-border text-zinc-500 dark:text-dark-muted">
|
||||||
{index + 1}
|
{startIndex + index + 1}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-2.5 font-semibold text-zinc-900 dark:text-dark-text">{client.name}</td>
|
<td className="px-6 py-2.5 font-semibold text-zinc-900 dark:text-dark-text">{client.name}</td>
|
||||||
@@ -127,7 +130,7 @@ const Clients = () => {
|
|||||||
<td className="px-6 py-2.5 text-right">
|
<td className="px-6 py-2.5 text-right">
|
||||||
<Link
|
<Link
|
||||||
to={`/clients/${encodeURIComponent(client.name)}`}
|
to={`/clients/${encodeURIComponent(client.name)}`}
|
||||||
className="inline-flex items-center text-xs font-bold text-brand-primary hover:opacity-80 transition-opacity"
|
className="inline-flex items-center text-xs font-bold text-brand-primary hover:opacity-80 transition-opacity cursor-pointer"
|
||||||
>
|
>
|
||||||
Ver detalhes
|
Ver detalhes
|
||||||
<ChevronRight className="w-3.5 h-3.5 ml-1" />
|
<ChevronRight className="w-3.5 h-3.5 ml-1" />
|
||||||
@@ -138,6 +141,49 @@ const Clients = () => {
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Pagination Controls */}
|
||||||
|
<div className="px-6 py-4 border-t border-zinc-100 dark:border-dark-border flex flex-col sm:flex-row items-center justify-between gap-4">
|
||||||
|
<div className="flex items-center gap-2 text-sm text-zinc-500 dark:text-dark-muted">
|
||||||
|
<span>Mostrar</span>
|
||||||
|
<select
|
||||||
|
value={itemsPerPage}
|
||||||
|
onChange={(e) => {
|
||||||
|
setItemsPerPage(Number(e.target.value));
|
||||||
|
setCurrentPage(1);
|
||||||
|
}}
|
||||||
|
className="bg-dark-card border border-dark-border rounded-lg px-2 py-1 focus:outline-none focus:border-brand-primary cursor-pointer text-dark-text"
|
||||||
|
>
|
||||||
|
<option value={10}>10</option>
|
||||||
|
<option value={20}>20</option>
|
||||||
|
<option value={50}>50</option>
|
||||||
|
<option value={100}>100</option>
|
||||||
|
</select>
|
||||||
|
<span>itens por página</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-4 text-sm">
|
||||||
|
<span className="text-zinc-500 dark:text-dark-muted">
|
||||||
|
Mostrando {clientsData.length > 0 ? startIndex + 1 : 0} a {Math.min(startIndex + itemsPerPage, clientsData.length)} de {clientsData.length} clientes
|
||||||
|
</span>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<button
|
||||||
|
onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
|
||||||
|
disabled={currentPage === 1}
|
||||||
|
className="p-1 rounded-lg border border-dark-border disabled:opacity-50 disabled:cursor-not-allowed hover:border-brand-primary transition-colors text-dark-muted hover:text-dark-text cursor-pointer bg-dark-card"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))}
|
||||||
|
disabled={currentPage === totalPages || totalPages === 0}
|
||||||
|
className="p-1 rounded-lg border border-dark-border disabled:opacity-50 disabled:cursor-not-allowed hover:border-brand-primary transition-colors text-dark-muted hover:text-dark-text cursor-pointer bg-dark-card"
|
||||||
|
>
|
||||||
|
<ChevronRight className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,53 +1,90 @@
|
|||||||
import { useMemo } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { useOutletContext } from 'react-router-dom';
|
import { useOutletContext, useNavigate } from 'react-router-dom';
|
||||||
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, PieChart, Pie, Cell } from 'recharts';
|
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, PieChart, Pie, Cell } from 'recharts';
|
||||||
import { DollarSign, ShoppingCart, TrendingUp } from 'lucide-react';
|
import { DollarSign, Loader2, ShoppingCart, TrendingUp } from 'lucide-react';
|
||||||
import DateRangePicker from '../components/DateRangePicker';
|
import DateRangePicker from '../components/DateRangePicker';
|
||||||
import type { OrderData, DateRange } from '../types';
|
import type { DashboardAnalytics, OrderData, DateRange } from '../types';
|
||||||
|
import { applyDashboardColors, buildDashboardMetrics } from '../analytics/dashboard';
|
||||||
|
import { fetchDashboardAnalytics } from '../dataService';
|
||||||
|
|
||||||
const COLORS = [
|
const formatCurrency = (value: number) => {
|
||||||
'#10b981', '#3b82f6', '#8b5cf6', '#f43f5e', '#f97316',
|
return new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(value);
|
||||||
'#06b6d4', '#ec4899', '#eab308', '#6366f1', '#14b8a6'
|
};
|
||||||
];
|
|
||||||
|
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;
|
||||||
|
const value = isCurrency ? formatCurrency(payload[0].value) : payload[0].value;
|
||||||
|
const valueLabel = isCurrency ? 'Receita:' : 'Vendas:';
|
||||||
|
return (
|
||||||
|
<div className="bg-[#141414] p-3 rounded-xl shadow-lg border-none">
|
||||||
|
<p className="font-bold mb-1" style={{ color }}>{displayLabel}</p>
|
||||||
|
<p className="text-[#ededed] m-0">{valueLabel} {value}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
const Dashboard = () => {
|
const Dashboard = () => {
|
||||||
const { dateRange, setDateRange, ordersData } = useOutletContext<{ dateRange: DateRange, setDateRange: (range: DateRange) => void, ordersData: OrderData[] }>();
|
const navigate = useNavigate();
|
||||||
|
const { dateRange, setDateRange, ordersData, refreshInterval, setRefreshInterval } = useOutletContext<{
|
||||||
|
dateRange: DateRange,
|
||||||
|
setDateRange: (range: DateRange) => void,
|
||||||
|
ordersData: OrderData[],
|
||||||
|
refreshInterval: number,
|
||||||
|
setRefreshInterval: (interval: number) => void,
|
||||||
|
}>();
|
||||||
|
const [serverMetrics, setServerMetrics] = useState<DashboardAnalytics | null>(null);
|
||||||
|
const [isMetricsLoading, setIsMetricsLoading] = useState(true);
|
||||||
|
|
||||||
const filteredData = useMemo(() => {
|
const loadDashboardMetrics = useCallback(async (range: DateRange) => {
|
||||||
const orders = ordersData;
|
setIsMetricsLoading(true);
|
||||||
return orders.filter(order => {
|
const metrics = await fetchDashboardAnalytics(range);
|
||||||
const [day, month, year] = order.Data_Pedido.split('-').map(Number);
|
setServerMetrics(metrics);
|
||||||
const orderDate = new Date(year, month - 1, day);
|
setIsMetricsLoading(false);
|
||||||
return orderDate >= dateRange.start && orderDate <= dateRange.end;
|
}, []);
|
||||||
});
|
|
||||||
}, [dateRange, ordersData]);
|
|
||||||
|
|
||||||
const { totalRevenue, totalOrders, averageOrderValue, salesByProduct } = useMemo(() => {
|
useEffect(() => {
|
||||||
let revenue = 0;
|
// Dashboard metrics are synchronized with the selected server-side date range.
|
||||||
let totalItems = 0;
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||||
const productSalesMap: Record<string, number> = {};
|
void loadDashboardMetrics(dateRange);
|
||||||
|
}, [dateRange, loadDashboardMetrics]);
|
||||||
|
|
||||||
filteredData.forEach(order => {
|
useEffect(() => {
|
||||||
revenue += (order.Quantidade * order.Valor_Unitario);
|
if (refreshInterval === 0) return;
|
||||||
totalItems += order.Quantidade;
|
|
||||||
const productName = order.Descricao_Produto.split(' TAMANHO')[0];
|
|
||||||
if (productSalesMap[productName]) {
|
|
||||||
productSalesMap[productName] += order.Quantidade;
|
|
||||||
} else {
|
|
||||||
productSalesMap[productName] = order.Quantidade;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const productsData = Object.keys(productSalesMap).map(key => ({
|
const intervalId = setInterval(() => {
|
||||||
name: key,
|
void loadDashboardMetrics(dateRange);
|
||||||
value: productSalesMap[key]
|
}, refreshInterval);
|
||||||
})).sort((a, b) => b.value - a.value).slice(0, 10);
|
|
||||||
|
|
||||||
return { totalRevenue: revenue, totalOrders: totalItems, averageOrderValue: revenue / (filteredData.length || 1), salesByProduct: productsData };
|
return () => clearInterval(intervalId);
|
||||||
}, [filteredData]);
|
}, [dateRange, loadDashboardMetrics, refreshInterval]);
|
||||||
|
|
||||||
const formatCurrency = (value: number) => {
|
const { totalRevenue, totalOrders, averageOrderValue, salesByProduct, revenueByProduct } = useMemo(() => {
|
||||||
return new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(value);
|
if (serverMetrics) return applyDashboardColors(serverMetrics);
|
||||||
|
return buildDashboardMetrics(ordersData, dateRange);
|
||||||
|
}, [dateRange, ordersData, serverMetrics]);
|
||||||
|
|
||||||
|
const handleManualRefresh = () => {
|
||||||
|
void loadDashboardMetrics(dateRange);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -57,9 +94,22 @@ const Dashboard = () => {
|
|||||||
<h1 className="text-2xl font-bold mb-2 text-dark-text">Visão Geral</h1>
|
<h1 className="text-2xl font-bold mb-2 text-dark-text">Visão Geral</h1>
|
||||||
<p className="text-dark-muted font-medium">Resumo de vendas e performance dos produtos.</p>
|
<p className="text-dark-muted font-medium">Resumo de vendas e performance dos produtos.</p>
|
||||||
</div>
|
</div>
|
||||||
<DateRangePicker dateRange={dateRange} onChange={setDateRange} />
|
<DateRangePicker
|
||||||
|
dateRange={dateRange}
|
||||||
|
onChange={setDateRange}
|
||||||
|
refreshInterval={refreshInterval}
|
||||||
|
setRefreshInterval={setRefreshInterval}
|
||||||
|
onManualRefresh={handleManualRefresh}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{isMetricsLoading && (
|
||||||
|
<div className="inline-flex items-center gap-2 rounded-xl border border-dark-border bg-dark-card px-3 py-2 text-sm font-semibold text-dark-muted">
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin text-brand-primary" />
|
||||||
|
Atualizando indicadores
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||||
<div className="bg-dark-card p-6 rounded-2xl border border-dark-border shadow-sm">
|
<div className="bg-dark-card p-6 rounded-2xl border border-dark-border shadow-sm">
|
||||||
<div className="flex justify-between items-start">
|
<div className="flex justify-between items-start">
|
||||||
@@ -99,58 +149,54 @@ const Dashboard = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
<div className="bg-dark-card p-6 rounded-2xl border border-dark-border shadow-sm">
|
<div className="bg-dark-card p-6 rounded-2xl border border-dark-border shadow-sm flex flex-col">
|
||||||
<h3 className="text-lg font-bold mb-6 text-dark-text">Produtos Mais Vendidos</h3>
|
<h3 className="text-lg font-bold mb-6 text-dark-text">Produtos Mais Vendidos</h3>
|
||||||
<div className="h-80 w-full">
|
<div className="h-80 w-full flex items-center justify-center">
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
<BarChart data={salesByProduct} margin={{ top: 5, right: 30, left: 20, bottom: 5 }}>
|
<BarChart data={salesByProduct} margin={{ top: 5, right: 30, left: 20, bottom: 5 }}>
|
||||||
<CartesianGrid strokeDasharray="3 3" stroke="#222222" vertical={false} />
|
<CartesianGrid strokeDasharray="3 3" stroke="#222222" vertical={false} />
|
||||||
<XAxis
|
<XAxis
|
||||||
dataKey="name" stroke="#888888" fontSize={10} tickLine={false} axisLine={false}
|
dataKey="name" stroke="#888888" fontSize={10} tickLine={false} axisLine={false}
|
||||||
tickFormatter={(v) => v.length > 12 ? v.substring(0, 12) + '...' : v}
|
tick={false}
|
||||||
/>
|
/>
|
||||||
<YAxis stroke="#888888" fontSize={12} tickLine={false} axisLine={false} />
|
<YAxis stroke="#888888" fontSize={12} tickLine={false} axisLine={false} />
|
||||||
<Tooltip
|
<Tooltip content={<CustomTooltip />} cursor={{ fill: '#222222' }} />
|
||||||
cursor={{ fill: '#222222' }}
|
<Bar dataKey="value" radius={[4, 4, 0, 0]} onClick={(data) => { if(data?.payload?.id) navigate(`/products/${data.payload.id}`) }} style={{ cursor: 'pointer' }}>
|
||||||
contentStyle={{
|
{salesByProduct.map((entry) => (
|
||||||
backgroundColor: '#141414', borderColor: 'transparent', borderRadius: '12px',
|
<Cell key={`cell-${entry.name}`} fill={entry.fill} style={{ cursor: 'pointer' }} />
|
||||||
boxShadow: '0 10px 15px -3px rgb(0 0 0 / 0.5)', border: 'none', color: '#ededed'
|
|
||||||
}}
|
|
||||||
itemStyle={{ color: '#ededed' }}
|
|
||||||
/>
|
|
||||||
<Bar dataKey="value" radius={[4, 4, 0, 0]}>
|
|
||||||
{salesByProduct.map((_, index) => (
|
|
||||||
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
|
|
||||||
))}
|
))}
|
||||||
</Bar>
|
</Bar>
|
||||||
</BarChart>
|
</BarChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="mt-4 grid grid-cols-2 gap-2">
|
||||||
|
{salesByProduct.map((entry) => (
|
||||||
|
<div key={`bar-legend-${entry.name}`} className="flex items-center text-[10px] cursor-pointer hover:opacity-80 transition-opacity" onClick={() => navigate(`/products/${entry.id}`)}>
|
||||||
|
<span className="w-2.5 h-2.5 rounded-full mr-2 shrink-0" style={{ backgroundColor: entry.fill }}></span>
|
||||||
|
<span className="text-dark-muted truncate font-semibold" title={entry.name}>{entry.name}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-dark-card p-6 rounded-2xl border border-dark-border shadow-sm">
|
<div className="bg-dark-card p-6 rounded-2xl border border-dark-border shadow-sm flex flex-col">
|
||||||
<h3 className="text-lg font-bold mb-6 text-dark-text">Distribuição de Produtos</h3>
|
<h3 className="text-lg font-bold mb-6 text-dark-text">Receita por Produto</h3>
|
||||||
<div className="h-80 w-full flex items-center justify-center">
|
<div className="h-80 w-full flex items-center justify-center">
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
<PieChart>
|
<PieChart>
|
||||||
<Pie data={salesByProduct} cx="50%" cy="50%" innerRadius={80} outerRadius={110} paddingAngle={5} dataKey="value" stroke="none">
|
<Pie data={revenueByProduct} cx="50%" cy="50%" innerRadius={80} outerRadius={110} paddingAngle={5} dataKey="value" stroke="none" onClick={(data) => { if(data?.payload?.id) navigate(`/products/${data.payload.id}`) }} style={{ cursor: 'pointer' }}>
|
||||||
{salesByProduct.map((_, index) => (
|
{revenueByProduct.map((entry) => (
|
||||||
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
|
<Cell key={`cell-${entry.name}`} fill={entry.fill} style={{ cursor: 'pointer' }} />
|
||||||
))}
|
))}
|
||||||
</Pie>
|
</Pie>
|
||||||
<Tooltip
|
<Tooltip content={<CustomTooltip isCurrency={true} />} cursor={{ fill: '#222222' }} />
|
||||||
contentStyle={{
|
|
||||||
backgroundColor: '#141414', borderColor: 'transparent', borderRadius: '12px',
|
|
||||||
boxShadow: '0 10px 15px -3px rgb(0 0 0 / 0.5)', border: 'none', color: '#ededed'
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</PieChart>
|
</PieChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-4 grid grid-cols-2 gap-2">
|
<div className="mt-4 grid grid-cols-2 gap-2">
|
||||||
{salesByProduct.map((entry, index) => (
|
{revenueByProduct.map((entry) => (
|
||||||
<div key={entry.name} className="flex items-center text-[10px]">
|
<div key={`pie-legend-${entry.name}`} className="flex items-center text-[10px] cursor-pointer hover:opacity-80 transition-opacity" onClick={() => navigate(`/products/${entry.id}`)}>
|
||||||
<span className="w-2.5 h-2.5 rounded-full mr-2 shrink-0" style={{ backgroundColor: COLORS[index % COLORS.length] }}></span>
|
<span className="w-2.5 h-2.5 rounded-full mr-2 shrink-0" style={{ backgroundColor: entry.fill }}></span>
|
||||||
<span className="text-dark-muted truncate font-semibold">{entry.name}</span>
|
<span className="text-dark-muted truncate font-semibold">{entry.name}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ const Login = () => {
|
|||||||
} else {
|
} else {
|
||||||
setError('E-mail ou senha incorretos.');
|
setError('E-mail ou senha incorretos.');
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch {
|
||||||
setError('Erro ao conectar ao servidor.');
|
setError('Erro ao conectar ao servidor.');
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
|
|||||||
@@ -4,54 +4,54 @@ import { ArrowLeft, Package, DollarSign } from 'lucide-react';
|
|||||||
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
|
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
|
||||||
import DateRangePicker from '../components/DateRangePicker';
|
import DateRangePicker from '../components/DateRangePicker';
|
||||||
import type { OrderData, DateRange } from '../types';
|
import type { OrderData, DateRange } from '../types';
|
||||||
|
import { buildProductDetailsMetrics } from '../analytics/products';
|
||||||
|
|
||||||
|
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">
|
||||||
|
<p className="font-bold mb-1" style={{ color: '#9ECAE1' }}>{label}</p>
|
||||||
|
<p className="text-[#ededed] m-0">Vendas: {payload[0].value}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
const ProductDetails = () => {
|
const ProductDetails = () => {
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
const { dateRange, setDateRange, ordersData } = useOutletContext<{ dateRange: DateRange, setDateRange: (range: DateRange) => void, ordersData: OrderData[] }>();
|
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 { productInfo, chartData, totalSold, totalRevenue } = useMemo(() => {
|
||||||
const orders = ordersData.filter(order => order.ID_Produto === id);
|
return buildProductDetailsMetrics(ordersData, id, dateRange);
|
||||||
|
|
||||||
if (orders.length === 0) return { productInfo: null, chartData: [], totalSold: 0, totalRevenue: 0 };
|
|
||||||
|
|
||||||
const info = {
|
|
||||||
id: orders[0].ID_Produto,
|
|
||||||
name: orders[0].Descricao_Produto.split(' TAMANHO')[0],
|
|
||||||
price: orders[0].Valor_Unitario
|
|
||||||
};
|
|
||||||
|
|
||||||
const salesByDate: Record<string, number> = {};
|
|
||||||
let sold = 0;
|
|
||||||
let revenue = 0;
|
|
||||||
|
|
||||||
orders.forEach(order => {
|
|
||||||
const [day, month, year] = order.Data_Pedido.split('-').map(Number);
|
|
||||||
const orderDate = new Date(year, month - 1, day);
|
|
||||||
|
|
||||||
if (orderDate >= dateRange.start && orderDate <= dateRange.end) {
|
|
||||||
const dateStr = order.Data_Pedido;
|
|
||||||
salesByDate[dateStr] = (salesByDate[dateStr] || 0) + order.Quantidade;
|
|
||||||
sold += order.Quantidade;
|
|
||||||
revenue += (order.Quantidade * order.Valor_Unitario);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const chart = Object.keys(salesByDate).map(date => ({
|
|
||||||
date,
|
|
||||||
value: salesByDate[date]
|
|
||||||
})).sort((a, b) => {
|
|
||||||
const [da, ma, ya] = a.date.split('-').map(Number);
|
|
||||||
const [db, mb, yb] = b.date.split('-').map(Number);
|
|
||||||
return new Date(ya, ma - 1, da).getTime() - new Date(yb, mb - 1, db).getTime();
|
|
||||||
});
|
|
||||||
|
|
||||||
return { productInfo: info, chartData: chart, totalSold: sold, totalRevenue: revenue };
|
|
||||||
}, [id, dateRange, ordersData]);
|
}, [id, dateRange, ordersData]);
|
||||||
|
|
||||||
const formatCurrency = (value: number) => {
|
const formatCurrency = (value: number) => {
|
||||||
return new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(value);
|
return new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(value);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (!productInfo && isDataLoading) {
|
||||||
|
return (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<p className="text-zinc-500 dark:text-dark-muted font-medium">Carregando produto...</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (!productInfo) {
|
if (!productInfo) {
|
||||||
return (
|
return (
|
||||||
<div className="text-center py-12">
|
<div className="text-center py-12">
|
||||||
@@ -81,8 +81,10 @@ const ProductDetails = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DateRangePicker dateRange={dateRange} onChange={setDateRange} />
|
<DateRangePicker
|
||||||
</div>
|
dateRange={dateRange}
|
||||||
|
onChange={setDateRange}
|
||||||
|
/> </div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
<div className="bg-dark-card p-6 rounded-2xl border border-dark-border flex items-center justify-between shadow-sm">
|
<div className="bg-dark-card p-6 rounded-2xl border border-dark-border flex items-center justify-between shadow-sm">
|
||||||
@@ -107,21 +109,19 @@ const ProductDetails = () => {
|
|||||||
|
|
||||||
<div className="bg-white dark:bg-dark-card border border-zinc-200 dark:border-dark-border rounded-2xl p-6 shadow-sm">
|
<div className="bg-white dark:bg-dark-card border border-zinc-200 dark:border-dark-border rounded-2xl p-6 shadow-sm">
|
||||||
<h3 className="text-lg font-bold mb-8 text-zinc-900 dark:text-dark-text">Volume de Vendas por Data</h3>
|
<h3 className="text-lg font-bold mb-8 text-zinc-900 dark:text-dark-text">Volume de Vendas por Data</h3>
|
||||||
<div className="h-80 w-full">
|
<div className="h-[400px] w-full">
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
<BarChart data={chartData}>
|
<BarChart data={chartData} margin={{ top: 5, right: 30, left: 20, bottom: 80 }}>
|
||||||
<CartesianGrid strokeDasharray="3 3" stroke="#222222" vertical={false} />
|
<CartesianGrid strokeDasharray="3 3" stroke="#222222" vertical={false} />
|
||||||
<XAxis
|
<XAxis
|
||||||
dataKey="date" stroke="#888888" fontSize={12} tickLine={false} axisLine={false}
|
dataKey="date" stroke="#888888" fontSize={10} tickLine={false} axisLine={false}
|
||||||
|
interval={0}
|
||||||
|
angle={-45}
|
||||||
|
textAnchor="end"
|
||||||
|
height={80}
|
||||||
/>
|
/>
|
||||||
<YAxis stroke="#888888" fontSize={12} tickLine={false} axisLine={false} />
|
<YAxis stroke="#888888" fontSize={12} tickLine={false} axisLine={false} />
|
||||||
<Tooltip
|
<Tooltip content={<CustomTooltip />} cursor={{ fill: '#222222' }} />
|
||||||
cursor={{ fill: '#222222' }}
|
|
||||||
contentStyle={{
|
|
||||||
backgroundColor: '#141414', borderColor: 'transparent', borderRadius: '12px',
|
|
||||||
boxShadow: '0 10px 15px -3px rgb(0 0 0 / 0.5)', border: 'none', color: '#ededed'
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Bar dataKey="value" fill="#9ECAE1" radius={[4, 4, 0, 0]} />
|
<Bar dataKey="value" fill="#9ECAE1" radius={[4, 4, 0, 0]} />
|
||||||
</BarChart>
|
</BarChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
|
|||||||
@@ -1,44 +1,35 @@
|
|||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import { Link, useOutletContext } from 'react-router-dom';
|
import { Link, useOutletContext } from 'react-router-dom';
|
||||||
import { Search, Package, TrendingUp } from 'lucide-react';
|
import { Search, Package, TrendingUp, ChevronLeft, ChevronRight, Download } from 'lucide-react';
|
||||||
import DateRangePicker from '../components/DateRangePicker';
|
import DateRangePicker from '../components/DateRangePicker';
|
||||||
import type { OrderData, DateRange } from '../types';
|
import type { OrderData, DateRange, StockData } from '../types';
|
||||||
|
import { exportToCSV } from '../dataService';
|
||||||
|
import { buildProductsSummary } from '../analytics/products';
|
||||||
|
|
||||||
const Products = () => {
|
const Products = () => {
|
||||||
const { dateRange, setDateRange, ordersData } = useOutletContext<{ dateRange: DateRange, setDateRange: (range: DateRange) => void, ordersData: OrderData[] }>();
|
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
|
||||||
|
}>();
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
|
||||||
|
// Pagination state
|
||||||
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
const [itemsPerPage, setItemsPerPage] = useState(10);
|
||||||
|
|
||||||
const productsData = useMemo(() => {
|
const productsData = useMemo(() => {
|
||||||
const orders = ordersData;
|
return buildProductsSummary(ordersData, stockData, dateRange, searchTerm);
|
||||||
const productMap: Record<string, { id: string, name: string, totalSold: number, revenue: number, lastPrice: number }> = {};
|
}, [dateRange, searchTerm, ordersData, stockData]);
|
||||||
|
|
||||||
orders.forEach(order => {
|
// Pagination logic
|
||||||
const [day, month, year] = order.Data_Pedido.split('-').map(Number);
|
const totalPages = Math.ceil(productsData.length / itemsPerPage);
|
||||||
const orderDate = new Date(year, month - 1, day);
|
const startIndex = (currentPage - 1) * itemsPerPage;
|
||||||
if (orderDate < dateRange.start || orderDate > dateRange.end) return;
|
const paginatedData = productsData.slice(startIndex, startIndex + itemsPerPage);
|
||||||
|
|
||||||
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]);
|
|
||||||
|
|
||||||
const formatCurrency = (value: number) => {
|
const formatCurrency = (value: number) => {
|
||||||
return new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(value);
|
return new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(value);
|
||||||
@@ -53,7 +44,13 @@ const Products = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col sm:flex-row gap-3">
|
<div className="flex flex-col sm:flex-row gap-3">
|
||||||
<DateRangePicker dateRange={dateRange} onChange={setDateRange} />
|
<DateRangePicker
|
||||||
|
dateRange={dateRange}
|
||||||
|
onChange={(range) => {
|
||||||
|
setDateRange(range);
|
||||||
|
setCurrentPage(1);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-zinc-400 dark:text-dark-muted w-5 h-5" />
|
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-zinc-400 dark:text-dark-muted w-5 h-5" />
|
||||||
@@ -61,10 +58,31 @@ const Products = () => {
|
|||||||
type="text"
|
type="text"
|
||||||
placeholder="Buscar por nome ou ID..."
|
placeholder="Buscar por nome ou ID..."
|
||||||
value={searchTerm}
|
value={searchTerm}
|
||||||
onChange={(e) => setSearchTerm(e.target.value)}
|
onChange={(e) => {
|
||||||
className="w-full md:w-64 bg-white dark:bg-dark-input border border-zinc-200 dark:border-dark-border text-zinc-900 dark:text-dark-text rounded-xl pl-10 pr-4 py-2.5 focus:outline-none focus:border-brand-primary focus:ring-2 focus:ring-brand-primary/20 transition-all shadow-sm"
|
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>
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
const exportData = productsData.map(product => ({
|
||||||
|
'ID Produto': product.id,
|
||||||
|
'Descrição': product.name,
|
||||||
|
'Preço Atual (R$)': product.lastPrice.toFixed(2).replace('.', ','),
|
||||||
|
'Total Vendido (un.)': product.totalSold,
|
||||||
|
'Receita Gerada (R$)': product.revenue.toFixed(2).replace('.', ',')
|
||||||
|
}));
|
||||||
|
exportToCSV(exportData, `produtos_${new Date().toISOString().split('T')[0]}.csv`);
|
||||||
|
}}
|
||||||
|
className="flex items-center justify-center gap-2 bg-dark-card border border-dark-border px-4 py-2.5 rounded-xl shadow-sm hover:border-brand-primary transition-colors text-sm font-medium text-dark-text cursor-pointer"
|
||||||
|
title="Exportar para CSV"
|
||||||
|
>
|
||||||
|
<Download size={16} className="text-brand-primary" />
|
||||||
|
<span className="hidden sm:inline">Exportar</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -76,12 +94,13 @@ const Products = () => {
|
|||||||
<th className="px-6 py-4 font-bold uppercase tracking-wider text-[10px]">ID Produto</th>
|
<th className="px-6 py-4 font-bold uppercase tracking-wider text-[10px]">ID Produto</th>
|
||||||
<th className="px-6 py-4 font-bold uppercase tracking-wider text-[10px]">Descrição</th>
|
<th className="px-6 py-4 font-bold uppercase tracking-wider text-[10px]">Descrição</th>
|
||||||
<th className="px-6 py-4 font-bold uppercase tracking-wider text-[10px]">Total Vendido</th>
|
<th className="px-6 py-4 font-bold uppercase tracking-wider text-[10px]">Total Vendido</th>
|
||||||
|
<th className="px-6 py-4 font-bold uppercase tracking-wider text-[10px]">Estoque</th>
|
||||||
<th className="px-6 py-4 font-bold uppercase tracking-wider text-[10px]">Receita Gerada</th>
|
<th className="px-6 py-4 font-bold uppercase tracking-wider text-[10px]">Receita Gerada</th>
|
||||||
<th className="px-6 py-4 font-bold uppercase tracking-wider text-[10px] text-right">Ações</th>
|
<th className="px-6 py-4 font-bold uppercase tracking-wider text-[10px] text-right">Ações</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-zinc-100 dark:divide-dark-border">
|
<tbody className="divide-y divide-zinc-100 dark:divide-dark-border">
|
||||||
{productsData.map((product) => (
|
{paginatedData.map((product) => (
|
||||||
<tr key={product.id} className="hover:bg-zinc-50/80 dark:hover:bg-dark-input/50 transition-colors group">
|
<tr key={product.id} className="hover:bg-zinc-50/80 dark:hover:bg-dark-input/50 transition-colors group">
|
||||||
<td className="px-6 py-2.5 font-mono text-[11px] text-zinc-400 dark:text-dark-muted">#{product.id}</td>
|
<td className="px-6 py-2.5 font-mono text-[11px] text-zinc-400 dark:text-dark-muted">#{product.id}</td>
|
||||||
<td className="px-6 py-2.5">
|
<td className="px-6 py-2.5">
|
||||||
@@ -94,11 +113,16 @@ const Products = () => {
|
|||||||
<span className="font-bold text-zinc-900 dark:text-dark-text">{product.totalSold} un.</span>
|
<span className="font-bold text-zinc-900 dark:text-dark-text">{product.totalSold} un.</span>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
<td className="px-6 py-2.5">
|
||||||
|
<span className="font-bold text-zinc-900 dark:text-dark-text">
|
||||||
|
{product.stock} un.
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
<td className="px-6 py-2.5 text-brand-primary font-bold">{formatCurrency(product.revenue)}</td>
|
<td className="px-6 py-2.5 text-brand-primary font-bold">{formatCurrency(product.revenue)}</td>
|
||||||
<td className="px-6 py-2.5 text-right">
|
<td className="px-6 py-2.5 text-right">
|
||||||
<Link
|
<Link
|
||||||
to={`/products/${product.id}`}
|
to={`/products/${product.id}`}
|
||||||
className="inline-flex items-center text-xs font-bold text-brand-primary hover:opacity-80 transition-opacity bg-brand-primary/10 px-3 py-1.5 rounded-lg"
|
className="inline-flex items-center text-xs font-bold text-brand-primary hover:opacity-80 transition-opacity bg-brand-primary/10 px-3 py-1.5 rounded-lg cursor-pointer"
|
||||||
>
|
>
|
||||||
<TrendingUp className="w-3.5 h-3.5 mr-1.5" />
|
<TrendingUp className="w-3.5 h-3.5 mr-1.5" />
|
||||||
Ver Gráfico
|
Ver Gráfico
|
||||||
@@ -109,6 +133,49 @@ const Products = () => {
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Pagination Controls */}
|
||||||
|
<div className="px-6 py-4 border-t border-zinc-100 dark:border-dark-border flex flex-col sm:flex-row items-center justify-between gap-4">
|
||||||
|
<div className="flex items-center gap-2 text-sm text-zinc-500 dark:text-dark-muted">
|
||||||
|
<span>Mostrar</span>
|
||||||
|
<select
|
||||||
|
value={itemsPerPage}
|
||||||
|
onChange={(e) => {
|
||||||
|
setItemsPerPage(Number(e.target.value));
|
||||||
|
setCurrentPage(1);
|
||||||
|
}}
|
||||||
|
className="bg-dark-card border border-dark-border rounded-lg px-2 py-1 focus:outline-none focus:border-brand-primary cursor-pointer text-dark-text"
|
||||||
|
>
|
||||||
|
<option value={10}>10</option>
|
||||||
|
<option value={20}>20</option>
|
||||||
|
<option value={50}>50</option>
|
||||||
|
<option value={100}>100</option>
|
||||||
|
</select>
|
||||||
|
<span>itens por página</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-4 text-sm">
|
||||||
|
<span className="text-zinc-500 dark:text-dark-muted">
|
||||||
|
Mostrando {productsData.length > 0 ? startIndex + 1 : 0} a {Math.min(startIndex + itemsPerPage, productsData.length)} de {productsData.length} produtos
|
||||||
|
</span>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<button
|
||||||
|
onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
|
||||||
|
disabled={currentPage === 1}
|
||||||
|
className="p-1 rounded-lg border border-dark-border disabled:opacity-50 disabled:cursor-not-allowed hover:border-brand-primary transition-colors text-dark-muted hover:text-dark-text cursor-pointer bg-dark-card"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))}
|
||||||
|
disabled={currentPage === totalPages || totalPages === 0}
|
||||||
|
className="p-1 rounded-lg border border-dark-border disabled:opacity-50 disabled:cursor-not-allowed hover:border-brand-primary transition-colors text-dark-muted hover:text-dark-text cursor-pointer bg-dark-card"
|
||||||
|
>
|
||||||
|
<ChevronRight className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
98
src/types.ts
98
src/types.ts
@@ -6,9 +6,107 @@ export interface OrderData {
|
|||||||
Descricao_Produto: string;
|
Descricao_Produto: string;
|
||||||
Quantidade: number;
|
Quantidade: number;
|
||||||
Valor_Unitario: number;
|
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 {
|
export interface DateRange {
|
||||||
start: Date;
|
start: Date;
|
||||||
end: Date;
|
end: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DashboardAnalytics {
|
||||||
|
totalRevenue: number;
|
||||||
|
totalOrders: number;
|
||||||
|
averageOrderValue: number;
|
||||||
|
salesByProduct: Array<{
|
||||||
|
name: string;
|
||||||
|
id: string;
|
||||||
|
value: number;
|
||||||
|
}>;
|
||||||
|
revenueByProduct: Array<{
|
||||||
|
name: string;
|
||||||
|
id: string;
|
||||||
|
value: number;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CampaignStatus = 'pending' | 'processing' | 'sent' | 'failed' | 'skipped';
|
||||||
|
|
||||||
|
export interface CampaignQueueItem {
|
||||||
|
id: number;
|
||||||
|
base_product_name: string;
|
||||||
|
produto_id: string;
|
||||||
|
nome: string;
|
||||||
|
saldo: number;
|
||||||
|
delta_estoque: number;
|
||||||
|
status: CampaignStatus;
|
||||||
|
attempts: number;
|
||||||
|
last_error?: string | null;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
sent_at?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CampaignGroup {
|
||||||
|
key: string;
|
||||||
|
baseProductName: string;
|
||||||
|
status: CampaignStatus;
|
||||||
|
totalDelta: number;
|
||||||
|
rowCount: number;
|
||||||
|
attempts: number;
|
||||||
|
lastError?: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
sentAt?: string | null;
|
||||||
|
items: CampaignQueueItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CampaignQueueSummary {
|
||||||
|
threshold: number;
|
||||||
|
maxAttempts: number;
|
||||||
|
groups: CampaignGroup[];
|
||||||
|
rows: CampaignQueueItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CampaignProductPreview {
|
||||||
|
baseProduct: string;
|
||||||
|
total_delta: number;
|
||||||
|
sizes: Array<{
|
||||||
|
id: string;
|
||||||
|
nome: string;
|
||||||
|
delta: number;
|
||||||
|
saldo: number;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CampaignPreview {
|
||||||
|
threshold: number;
|
||||||
|
readyProducts: CampaignProductPreview[];
|
||||||
|
belowThresholdProducts: CampaignProductPreview[];
|
||||||
|
productsText: string;
|
||||||
|
customerCount: number;
|
||||||
|
customersPreview: Array<{
|
||||||
|
nome: string;
|
||||||
|
fone: string;
|
||||||
|
total_gasto?: string;
|
||||||
|
total_comprado?: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CampaignProcessSummary {
|
||||||
|
claimed: number;
|
||||||
|
sentGroups: number;
|
||||||
|
skippedGroups: number;
|
||||||
|
failedGroups: number;
|
||||||
|
pendingBelowThresholdGroups: number;
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,8 +3,27 @@ import react from '@vitejs/plugin-react'
|
|||||||
import tailwindcss from '@tailwindcss/vite'
|
import tailwindcss from '@tailwindcss/vite'
|
||||||
|
|
||||||
// https://vite.dev/config/
|
// https://vite.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig(({ command }) => ({
|
||||||
plugins: [react(), tailwindcss()],
|
plugins: [
|
||||||
|
command === 'serve' && {
|
||||||
|
name: 'react-refresh-preamble-fix',
|
||||||
|
transformIndexHtml() {
|
||||||
|
return [{
|
||||||
|
tag: 'script',
|
||||||
|
attrs: { type: 'module' },
|
||||||
|
children: `
|
||||||
|
import RefreshRuntime from "/@react-refresh";
|
||||||
|
RefreshRuntime.injectIntoGlobalHook(window);
|
||||||
|
window.$RefreshReg$ = () => {};
|
||||||
|
window.$RefreshSig$ = () => (type) => type;
|
||||||
|
window.__vite_plugin_react_preamble_installed__ = true;
|
||||||
|
`
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
react(),
|
||||||
|
tailwindcss()
|
||||||
|
],
|
||||||
server: {
|
server: {
|
||||||
port: 3001,
|
port: 3001,
|
||||||
proxy: {
|
proxy: {
|
||||||
@@ -13,5 +32,14 @@ export default defineConfig({
|
|||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
preview: {
|
||||||
|
port: 3000,
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://localhost:3004',
|
||||||
|
changeOrigin: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
}))
|
||||||
|
|||||||
Reference in New Issue
Block a user