Compare commits

...

57 Commits

Author SHA1 Message Date
Cauê Faleiros
0d6ef40c8e fix: preserve etiqueta product variants
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 42s
2026-06-01 09:54:30 -03:00
Cauê Faleiros
fce7bbf975 fix: title case campaign product names 2026-06-01 09:47:35 -03:00
Cauê Faleiros
a1aa071e1d fix: normalize campaign product size suffixes
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m44s
2026-06-01 09:27:54 -03:00
Cauê Faleiros
b886b357d7 perf: load dashboard metrics from analytics API
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 50s
2026-05-28 11:59:56 -03:00
Cauê Faleiros
f4cf4366ee perf: avoid blocking page render on data refresh
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 41s
2026-05-28 11:46:11 -03:00
Cauê Faleiros
6dbc5ee190 perf: code split frontend routes
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 54s
2026-05-28 11:31:50 -03:00
Cauê Faleiros
cfdeb03786 feat: add backend analytics endpoints 2026-05-28 11:28:06 -03:00
Cauê Faleiros
fd89204973 feat: normalize order dates in database 2026-05-28 11:23:47 -03:00
Cauê Faleiros
3da299a8af test: add campaign queue coverage 2026-05-28 11:18:45 -03:00
Cauê Faleiros
e2d0e94080 add campaign observability page
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 43s
2026-05-28 11:07:46 -03:00
Cauê Faleiros
6c0a78675c chore: persist local docker stack
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m39s
2026-05-28 10:49:46 -03:00
Cauê Faleiros
440c8cee1f combine ready products in campaign payload
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 46s
2026-05-27 16:31:26 -03:00
Cauê Faleiros
62a0bcfbc9 docs: add project context and remove boilerplate
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 44s
2026-05-27 16:18:10 -03:00
Cauê Faleiros
5e0bb1d83a accumulate stock deltas before campaigns
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 52s
2026-05-27 16:14:09 -03:00
Cauê Faleiros
72ded82ec7 extract frontend analytics helpers
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m12s
2026-05-27 15:58:12 -03:00
Cauê Faleiros
8c2590c56a refactor backend and persist stock campaign queue
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m32s
2026-05-27 15:00:23 -03:00
Cauê Faleiros
6ba8219596 fix: map N8N_WHATSAPP_TRIGGER_URL to backend container in docker-compose
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m7s
2026-05-26 15:57:53 -03:00
Cauê Faleiros
69f99b97c5 feat: add observability logs to stock waiting room for easier debugging in Portainer
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m8s
2026-05-26 15:11:00 -03:00
Cauê Faleiros
1f8baabf69 style: remove dynamic color coding from stock column to match table styling
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m26s
2026-05-25 11:35:15 -03:00
Cauê Faleiros
c47a64d831 feat: display available stock balance on products page based on n8n inventory updates 2026-05-25 11:26:00 -03:00
Cauê Faleiros
4ce1e9aedb style: remove WhatsApp link from client phone number
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m24s
2026-05-22 13:57:13 -03:00
Cauê Faleiros
fc8a5e47a0 fix: send Fone_Cliente from backend to frontend API response 2026-05-22 13:40:31 -03:00
Cauê Faleiros
174bb4841e feat: display customer phone number on client details page with WhatsApp quick link 2026-05-22 12:10:10 -03:00
Cauê Faleiros
9e52b2e44f feat: safely implement database idempotency, fallback IDs, and WhatsApp marketing export
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m30s
2026-05-22 10:54:54 -03:00
Cauê Faleiros
c77da0a9d0 feat: translate CSV export headers to pt-BR and format currency values
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m3s
2026-05-20 10:33:03 -03:00
Cauê Faleiros
ceecbc354d feat: add CSV export functionality to Clients and Products pages 2026-05-20 10:27:04 -03:00
Cauê Faleiros
2d85d2dcd5 feat: add date filter to clients page to analyze purchasing behavior within specific timeframes
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 50s
2026-05-15 11:46:26 -03:00
Cauê Faleiros
985d182743 fix: correctly initialize auto-refresh interval state from localStorage
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m7s
2026-05-15 11:10:27 -03:00
Cauê Faleiros
6aa9fff34c feat: persist auto-refresh interval across page reloads using localStorage
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m3s
2026-05-15 09:43:59 -03:00
Cauê Faleiros
fbd35d65af feat: make charts and legends clickable to navigate to product details
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m2s
2026-05-11 16:49:06 -03:00
Cauê Faleiros
8be7dbbe06 feat: make database idempotent by adding unique index and using UPSERT for order insertions
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m2s
2026-05-08 15:48:37 -03:00
Cauê Faleiros
d4fad9f6c1 style: replace insertion timestamp with original purchase date in client details 2026-05-08 15:33:48 -03:00
Cauê Faleiros
f6d4be1afb fix: safely parse n8n stringified numbers and add ID_Pedido to API response
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m16s
2026-05-08 14:10:06 -03:00
Cauê Faleiros
e66a90d583 feat: parse and display n8n order ID instead of data do pedido in client details 2026-05-08 10:58:36 -03:00
Cauê Faleiros
7959e18210 style: unify filter UI, add custom to-from date picker, and ensure all buttons use pointer cursors
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m10s
2026-05-07 16:05:22 -03:00
Cauê Faleiros
802558510f fix: override native browser date input format to explicitly show DD/MM/YY in custom period selector 2026-05-07 15:55:43 -03:00
Cauê Faleiros
df5f60e540 style: unify filter UI, add custom to-from date picker, and ensure all buttons use pointer cursors 2026-05-07 15:18:32 -03:00
Cauê Faleiros
b048c963dd feat: hide auto-refresh controls outside of dashboard and update refresh UI design 2026-05-07 15:07:44 -03:00
Cauê Faleiros
b986eafb98 feat: replace SSE with Grafana-style client polling and rich date presets 2026-05-07 14:52:45 -03:00
Cauê Faleiros
3bb46cff1a fix: stabilize color mapping by using persistent global hash and add SSE heartbeat
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m18s
2026-05-07 14:08:38 -03:00
Cauê Faleiros
d3167dbac1 fix: add X-Accel-Buffering header to bypass Nginx Proxy Manager buffering for SSE
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 40s
2026-05-07 13:25:18 -03:00
Cauê Faleiros
44028d3b41 feat: add pagination to Clients and Products pages to improve performance with large datasets
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m9s
2026-05-07 12:44:35 -03:00
Cauê Faleiros
4324e8e078 fix: disable nginx proxy buffering to allow SSE real-time updates in production
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 52s
2026-05-07 12:00:21 -03:00
Cauê Faleiros
e7f39a1e35 feat: implement consistent 20-color mapping for visible products across dashboard charts
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m30s
2026-05-07 11:28:37 -03:00
Cauê Faleiros
9e20dc997a feat: implement consistent 20-color mapping for visible products across dashboard charts
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 55s
2026-05-06 18:23:53 -03:00
Cauê Faleiros
a7bdd07c09 feat: implement consistent color mapping for products across charts
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m4s
2026-05-06 17:44:49 -03:00
Cauê Faleiros
c15de19180 feat: display revenue as currency in PieChart tooltip
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m12s
2026-05-06 17:24:11 -03:00
Cauê Faleiros
8f17f7b4fd feat: change PieChart to display Revenue by Product instead of quantity 2026-05-06 17:07:56 -03:00
Cauê Faleiros
8eb5d34247 fix: resolve SyntaxError in backend/index.js causing server crash
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 47s
2026-05-06 16:48:31 -03:00
Cauê Faleiros
16962c89ee feat: implement SSE real-time frontend updates on new payload reception
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 57s
2026-05-06 16:12:05 -03:00
Cauê Faleiros
c409276698 feat: unify chart legend styles and fix dynamic tooltip colors
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 48s
2026-05-06 15:45:24 -03:00
Cauê Faleiros
2c1e75593c feat: increase chart container heights and implement dynamic colored tooltips
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m16s
2026-05-06 15:20:32 -03:00
Cauê Faleiros
6c2ed8c301 fix: significantly increase X-axis height and margin to completely prevent label cutoff
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 43s
2026-05-06 14:33:35 -03:00
Cauê Faleiros
1d45dd3649 fix: force all X-axis labels to display on charts and prevent cutoff
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m9s
2026-05-06 14:07:10 -03:00
Cauê Faleiros
cf3f79b3da fix: invert backend sorting to make new payloads appear first and fix group sorting
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 51s
2026-05-06 11:09:25 -03:00
Cauê Faleiros
41a1afc0e5 fix: format reception timestamp as DD/MM/YYYY HH:MM:SS
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 56s
2026-05-06 10:42:27 -03:00
Cauê Faleiros
00942fd9b1 feat: display payload reception timestamp on client details page for debugging 2026-05-06 10:37:45 -03:00
51 changed files with 3103 additions and 895 deletions

View File

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

99
CONTEXT.md Normal file
View File

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

169
README.md
View File

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

40
backend/auth.js Normal file
View File

@@ -0,0 +1,40 @@
const jwt = require('jsonwebtoken');
const { ADMIN_EMAIL, ADMIN_PASSWORD, API_KEY, JWT_SECRET } = require('./config');
const verifyToken = (req, res, next) => {
const authHeader = req.headers.authorization;
if (!authHeader) return res.status(403).json({ error: 'No token provided' });
const token = authHeader.split(' ')[1];
if (!token) return res.status(403).json({ error: 'Malformed token' });
jwt.verify(token, JWT_SECRET, (err, decoded) => {
if (err) return res.status(401).json({ error: 'Unauthorized' });
req.user = decoded;
next();
});
};
const authenticateAPIKey = (req, res, next) => {
const apiKey = req.headers['x-api-key'];
if (apiKey === API_KEY) {
next();
return;
}
res.status(401).json({ error: 'Unauthorized: Invalid API Key' });
};
const login = (email, password) => {
if (email !== ADMIN_EMAIL || password !== ADMIN_PASSWORD) {
return null;
}
return jwt.sign({ email }, JWT_SECRET, { expiresIn: '24h' });
};
module.exports = {
verifyToken,
authenticateAPIKey,
login
};

11
backend/config.js Normal file
View File

@@ -0,0 +1,11 @@
require('dotenv').config();
module.exports = {
PORT: process.env.PORT || 3004,
API_KEY: process.env.API_KEY || 'nexstar_secret_key_123',
ADMIN_EMAIL: process.env.ADMIN_EMAIL || 'admin@admin.com',
ADMIN_PASSWORD: process.env.ADMIN_PASSWORD || 'admin123',
JWT_SECRET: process.env.JWT_SECRET || 'super_secret_jwt_key_123',
DATABASE_URL: process.env.DATABASE_URL || 'postgres://graphuser:graphpassword@localhost:5432/graphdb',
N8N_WHATSAPP_TRIGGER_URL: process.env.N8N_WHATSAPP_TRIGGER_URL || 'http://localhost:5678/webhook/whatsapp'
};

136
backend/db.js Normal file
View File

@@ -0,0 +1,136 @@
const { Pool } = require('pg');
const { DATABASE_URL } = require('./config');
const pool = new Pool({
connectionString: DATABASE_URL
});
const initDB = async () => {
try {
await pool.query(`SET TIME ZONE 'America/Sao_Paulo';`);
await pool.query(`
CREATE TABLE IF NOT EXISTS orders (
id SERIAL PRIMARY KEY,
cliente_nome VARCHAR(255),
data_pedido VARCHAR(50),
data_pedido_date DATE,
valor_pedido NUMERIC(10, 2),
produto_id VARCHAR(100),
produto_descricao TEXT,
quantidade INTEGER,
valor_unitario NUMERIC(10, 5),
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
`);
await pool.query(`ALTER TABLE orders ADD COLUMN IF NOT EXISTS pedido_id VARCHAR(100);`).catch(() => {});
await pool.query(`ALTER TABLE orders ADD COLUMN IF NOT EXISTS cliente_fone VARCHAR(50);`).catch(() => {});
await pool.query(`ALTER TABLE orders ADD COLUMN IF NOT EXISTS data_pedido_date DATE;`).catch(() => {});
await pool.query(`
ALTER TABLE orders
ALTER COLUMN created_at TYPE TIMESTAMPTZ USING created_at AT TIME ZONE 'America/Sao_Paulo',
ALTER COLUMN created_at SET DEFAULT CURRENT_TIMESTAMP;
`).catch(() => {});
await pool.query(`
UPDATE orders
SET data_pedido_date = CASE
WHEN data_pedido ~ '^\\d{4}[-/]\\d{1,2}[-/]\\d{1,2}' THEN to_date(replace(left(data_pedido, 10), '/', '-'), 'YYYY-MM-DD')
WHEN data_pedido ~ '^\\d{1,2}[-/]\\d{1,2}[-/]\\d{4}' THEN to_date(replace(left(data_pedido, 10), '/', '-'), 'DD-MM-YYYY')
ELSE NULL
END
WHERE data_pedido_date IS NULL
AND data_pedido IS NOT NULL
AND data_pedido != '';
`).catch(err => {
console.error('Notice: Could not backfill normalized order dates:', err.message);
});
await pool.query(`CREATE UNIQUE INDEX IF NOT EXISTS unique_order_product ON orders (pedido_id, produto_id);`).catch(err => {
console.error('Notice: Could not create unique index (might already exist or there are duplicates):', err.message);
});
await pool.query(`
CREATE TABLE IF NOT EXISTS stock (
produto_id VARCHAR(100) PRIMARY KEY,
nome TEXT,
saldo INTEGER DEFAULT 0,
delta_estoque INTEGER DEFAULT 0,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
`);
await pool.query(`
ALTER TABLE stock
ALTER COLUMN updated_at TYPE TIMESTAMPTZ USING updated_at AT TIME ZONE 'America/Sao_Paulo',
ALTER COLUMN updated_at SET DEFAULT CURRENT_TIMESTAMP;
`).catch(() => {});
await pool.query(`
CREATE TABLE IF NOT EXISTS stock_campaign_queue (
id SERIAL PRIMARY KEY,
base_product_name TEXT NOT NULL,
produto_id VARCHAR(100) NOT NULL,
nome TEXT NOT NULL,
saldo INTEGER DEFAULT 0,
delta_estoque INTEGER DEFAULT 0,
status VARCHAR(20) DEFAULT 'pending',
attempts INTEGER DEFAULT 0,
last_error TEXT,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
sent_at TIMESTAMPTZ
);
`);
await pool.query(`
ALTER TABLE stock_campaign_queue
ALTER COLUMN created_at TYPE TIMESTAMPTZ USING created_at AT TIME ZONE 'America/Sao_Paulo',
ALTER COLUMN created_at SET DEFAULT CURRENT_TIMESTAMP,
ALTER COLUMN updated_at TYPE TIMESTAMPTZ USING updated_at AT TIME ZONE 'America/Sao_Paulo',
ALTER COLUMN updated_at SET DEFAULT CURRENT_TIMESTAMP,
ALTER COLUMN sent_at TYPE TIMESTAMPTZ USING sent_at AT TIME ZONE 'America/Sao_Paulo';
`).catch(() => {});
await pool.query(`
UPDATE stock_campaign_queue
SET base_product_name = TRIM(regexp_replace(
base_product_name,
'\\s+-\\s+(?:(?:PP|P|M|G|GG|XG|XGG|EG|EGG|EXG|U|UNICO|ÚNICO|\\d{2})(?:/(?:PP|P|M|G|GG|XG|XGG|EG|EGG|EXG|U|UNICO|ÚNICO|\\d{2}))*)$',
'',
'i'
))
WHERE status IN ('pending', 'failed', 'processing')
AND base_product_name ~* '\\s+-\\s+(?:(?:PP|P|M|G|GG|XG|XGG|EG|EGG|EXG|U|UNICO|ÚNICO|\\d{2})(?:/(?:PP|P|M|G|GG|XG|XGG|EG|EGG|EXG|U|UNICO|ÚNICO|\\d{2}))*)$';
`).catch(err => {
console.error('Notice: Could not normalize queued campaign product names:', err.message);
});
await pool.query(`
UPDATE stock_campaign_queue
SET base_product_name = nome
WHERE status IN ('pending', 'failed', 'processing')
AND nome ILIKE 'ETIQUETA%'
AND base_product_name != nome;
`).catch(err => {
console.error('Notice: Could not restore queued etiqueta product names:', err.message);
});
await pool.query(`CREATE INDEX IF NOT EXISTS idx_stock_campaign_queue_status ON stock_campaign_queue (status);`);
await pool.query(`CREATE INDEX IF NOT EXISTS idx_orders_cliente_fone ON orders (cliente_fone);`);
await pool.query(`CREATE INDEX IF NOT EXISTS idx_orders_produto_id ON orders (produto_id);`);
await pool.query(`CREATE INDEX IF NOT EXISTS idx_orders_data_pedido_date ON orders (data_pedido_date);`);
console.log('Database initialized successfully.');
} catch (err) {
console.error('Failed to initialize database:', err);
throw err;
}
};
module.exports = {
pool,
initDB
};

View File

@@ -1,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 ADMIN_EMAIL = process.env.ADMIN_EMAIL || 'admin@admin.com';
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || 'admin123';
const JWT_SECRET = process.env.JWT_SECRET || 'super_secret_jwt_key_123';
app.use(cors());
app.use(bodyParser.json());
// PostgreSQL Connection Pool
const pool = new Pool({
connectionString: process.env.DATABASE_URL || 'postgres://graphuser:graphpassword@localhost:5432/graphdb',
});
// Initialize Database Table
const initDB = async () => {
try {
await pool.query(`
CREATE TABLE IF NOT EXISTS orders (
id SERIAL PRIMARY KEY,
cliente_nome VARCHAR(255),
data_pedido VARCHAR(50),
valor_pedido NUMERIC(10, 2),
produto_id VARCHAR(100),
produto_descricao TEXT,
quantidade INTEGER,
valor_unitario NUMERIC(10, 5),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
`);
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
app.post('/api/login', (req, res) => {
const { email, password } = req.body;
if (email === ADMIN_EMAIL && password === ADMIN_PASSWORD) {
const token = jwt.sign({ email }, JWT_SECRET, { expiresIn: '24h' });
res.json({ token });
} else {
res.status(401).json({ error: 'Invalid credentials' });
}
});
// 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();
}
})();
});
const app = createApp();
app.listen(PORT, '0.0.0.0', () => { app.listen(PORT, '0.0.0.0', () => {
console.log(`Nexstar Backend running at http://localhost:${PORT}`); console.log(`Nexstar Backend running at http://localhost:${PORT}`);
console.log(`Endpoint for n8n: POST http://localhost:${PORT}/api/data`); 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`);
});
};
start().catch((error) => {
console.error('Failed to start backend:', error);
process.exit(1);
}); });

View File

@@ -0,0 +1,62 @@
const formatOrderRow = (row) => ({
Nome_Cliente: row.cliente_nome,
Data_Pedido: row.data_pedido,
Valor_Pedido: parseFloat(row.valor_pedido),
ID_Produto: row.produto_id,
Descricao_Produto: row.produto_descricao,
Quantidade: row.quantidade,
Valor_Unitario: parseFloat(row.valor_unitario),
Recebido_Em: row.created_at,
ID_Pedido: row.pedido_id,
Fone_Cliente: row.cliente_fone
});
const normalizeOrderDate = (dateValue) => {
if (!dateValue) return null;
const value = String(dateValue).trim();
const match = value.match(/^(\d{1,4})[-/](\d{1,2})[-/](\d{1,4})/);
if (!match) return null;
const [, first, second, third] = match;
const year = first.length === 4 ? Number(first) : Number(third);
const month = Number(second);
const day = first.length === 4 ? Number(third) : Number(first);
const date = new Date(Date.UTC(year, month - 1, day));
if (
date.getUTCFullYear() !== year ||
date.getUTCMonth() !== month - 1 ||
date.getUTCDate() !== day
) {
return null;
}
return date.toISOString().slice(0, 10);
};
const normalizeOrderPayload = (item) => {
const fallbackId = `${item.Nome_Cliente}_${item.Data_Pedido}_${item.Valor_Pedido}`;
const orderId = item.id || item.ID_Pedido || (item.json && item.json.body && item.json.body.id) || fallbackId;
const fone = item.Fone_Cliente || item.fone || item.celular || '';
const orderDate = item.Data_Pedido || '';
return [
item.Nome_Cliente || 'Unknown',
orderDate,
normalizeOrderDate(orderDate),
parseFloat(item.Valor_Pedido) || 0,
item.ID_Produto || '',
item.Descricao_Produto || '',
parseInt(item.Quantidade, 10) || 0,
parseFloat(item.Valor_Unitario) || 0,
String(orderId),
String(fone)
];
};
module.exports = {
formatOrderRow,
normalizeOrderDate,
normalizeOrderPayload
};

View File

@@ -0,0 +1,31 @@
const SIZE_SUFFIX_PATTERN = /\s+-\s+(?:(?:PP|P|M|G|GG|XG|XGG|EG|EGG|EXG|U|UNICO|ÚNICO|\d{2})(?:\/(?:PP|P|M|G|GG|XG|XGG|EG|EGG|EXG|U|UNICO|ÚNICO|\d{2}))*)$/i;
const getBaseProductName = (name) => {
const productName = String(name || 'Unknown').trim();
if (productName.toLocaleUpperCase('pt-BR').startsWith('ETIQUETA')) {
return productName;
}
return productName
.split(' TAMANHO')[0]
.replace(SIZE_SUFFIX_PATTERN, '')
.trim();
};
const normalizeStockPayload = (item) => {
const produtoId = item.idProduto || item.ID_Produto || '';
const nome = item.nome || item.Descricao_Produto || 'Unknown';
return {
produtoId: String(produtoId),
nome,
baseProductName: getBaseProductName(nome),
saldo: parseInt(item.saldo, 10) || 0,
deltaEstoque: parseInt(item.delta_estoque, 10) || 0
};
};
module.exports = {
getBaseProductName,
normalizeStockPayload
};

View File

@@ -5,7 +5,7 @@
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {
"start": "node index.js", "start": "node index.js",
"test": "echo \"Error: no test specified\" && exit 1" "test": "node --test"
}, },
"keywords": [], "keywords": [],
"author": "", "author": "",

View File

@@ -0,0 +1,43 @@
const express = require('express');
const { verifyToken } = require('../auth');
const {
getClientAnalytics,
getDashboardAnalytics,
getProductAnalytics
} = require('../services/analyticsService');
const router = express.Router();
const getRange = (query) => ({
start: query.start,
end: query.end
});
router.get('/analytics/dashboard', verifyToken, async (req, res) => {
try {
res.json(await getDashboardAnalytics(getRange(req.query)));
} catch (error) {
console.error('Error fetching dashboard analytics:', error);
res.status(500).json({ error: 'Internal Server Error' });
}
});
router.get('/analytics/products', verifyToken, async (req, res) => {
try {
res.json(await getProductAnalytics(getRange(req.query)));
} catch (error) {
console.error('Error fetching product analytics:', error);
res.status(500).json({ error: 'Internal Server Error' });
}
});
router.get('/analytics/clients', verifyToken, async (req, res) => {
try {
res.json(await getClientAnalytics(getRange(req.query)));
} catch (error) {
console.error('Error fetching client analytics:', error);
res.status(500).json({ error: 'Internal Server Error' });
}
});
module.exports = router;

View File

@@ -0,0 +1,18 @@
const express = require('express');
const { login } = require('../auth');
const router = express.Router();
router.post('/login', (req, res) => {
const { email, password } = req.body;
const token = login(email, password);
if (!token) {
res.status(401).json({ error: 'Invalid credentials' });
return;
}
res.json({ token });
});
module.exports = router;

View File

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

View File

@@ -0,0 +1,26 @@
const express = require('express');
const { authenticateAPIKey, verifyToken } = require('../auth');
const { listOrders, upsertOrders } = require('../services/ordersService');
const router = express.Router();
router.get('/data', verifyToken, async (req, res) => {
try {
res.json(await listOrders());
} catch (error) {
console.error('Error fetching data:', error);
res.status(500).json({ error: 'Internal Server Error' });
}
});
router.post('/data', authenticateAPIKey, async (req, res) => {
res.status(201).json({ message: 'Data received, processing in background' });
const payload = Array.isArray(req.body) ? req.body : [req.body];
upsertOrders(payload).catch((error) => {
console.error('Database insert error:', error);
});
});
module.exports = router;

View File

@@ -0,0 +1,17 @@
const express = require('express');
const { authenticateAPIKey } = require('../auth');
const { processPendingStockCampaigns } = require('../services/campaignService');
const router = express.Router();
router.post('/process-stock-campaigns', authenticateAPIKey, async (req, res) => {
try {
const summary = await processPendingStockCampaigns();
res.json(summary);
} catch (error) {
console.error('Error processing stock campaigns:', error);
res.status(500).json({ error: 'Internal Server Error' });
}
});
module.exports = router;

View File

@@ -0,0 +1,26 @@
const express = require('express');
const { authenticateAPIKey, verifyToken } = require('../auth');
const { listStock, upsertStockItems } = require('../services/stockService');
const router = express.Router();
router.get('/stock', verifyToken, async (req, res) => {
try {
res.json(await listStock());
} catch (error) {
console.error('Error fetching stock:', error);
res.status(500).json({ error: 'Internal Server Error' });
}
});
router.post('/stock', authenticateAPIKey, async (req, res) => {
res.status(201).json({ message: 'Stock data received, processing in background' });
const payload = Array.isArray(req.body) ? req.body : [req.body];
upsertStockItems(payload).catch((error) => {
console.error('Database stock insert error:', error);
});
});
module.exports = router;

29
backend/server.js Normal file
View File

@@ -0,0 +1,29 @@
const express = require('express');
const cors = require('cors');
const bodyParser = require('body-parser');
const authRoutes = require('./routes/authRoutes');
const dataRoutes = require('./routes/dataRoutes');
const stockRoutes = require('./routes/stockRoutes');
const campaignRoutes = require('./routes/campaignRoutes');
const internalRoutes = require('./routes/internalRoutes');
const analyticsRoutes = require('./routes/analyticsRoutes');
const createApp = () => {
const app = express();
app.use(cors());
app.use(bodyParser.json());
app.use('/api', authRoutes);
app.use('/api', dataRoutes);
app.use('/api', stockRoutes);
app.use('/api', campaignRoutes);
app.use('/api', analyticsRoutes);
app.use('/api/internal', internalRoutes);
return app;
};
module.exports = {
createApp
};

View File

@@ -0,0 +1,183 @@
const { pool } = require('../db');
const SIZE_SUFFIX_SQL_PATTERN = '\\s+-\\s+(?:(?:PP|P|M|G|GG|XG|XGG|EG|EGG|EXG|U|UNICO|ÚNICO|\\d{2})(?:/(?:PP|P|M|G|GG|XG|XGG|EG|EGG|EXG|U|UNICO|ÚNICO|\\d{2}))*)$';
const PRODUCT_NAME_SQL = `
CASE
WHEN COALESCE(produto_descricao, 'Unknown') ILIKE 'ETIQUETA%' THEN COALESCE(produto_descricao, 'Unknown')
ELSE NULLIF(TRIM(regexp_replace(split_part(COALESCE(produto_descricao, 'Unknown'), ' TAMANHO', 1), '${SIZE_SUFFIX_SQL_PATTERN}', '', 'i')), '')
END
`;
const normalizeDateParam = (value) => {
if (!value) return null;
const match = String(value).trim().match(/^(\d{4})-(\d{2})-(\d{2})$/);
if (!match) return null;
const [, yearValue, monthValue, dayValue] = match;
const year = Number(yearValue);
const month = Number(monthValue);
const day = Number(dayValue);
const date = new Date(Date.UTC(year, month - 1, day));
if (
date.getUTCFullYear() !== year ||
date.getUTCMonth() !== month - 1 ||
date.getUTCDate() !== day
) {
return null;
}
return `${yearValue}-${monthValue}-${dayValue}`;
};
const buildDateFilter = ({ start, end } = {}) => {
const params = [];
const filters = ['data_pedido_date IS NOT NULL'];
const normalizedStart = normalizeDateParam(start);
const normalizedEnd = normalizeDateParam(end);
if (normalizedStart) {
params.push(normalizedStart);
filters.push(`data_pedido_date >= $${params.length}::date`);
}
if (normalizedEnd) {
params.push(normalizedEnd);
filters.push(`data_pedido_date <= $${params.length}::date`);
}
return {
params,
whereClause: `WHERE ${filters.join(' AND ')}`
};
};
const toNumber = (value) => Number(value || 0);
const getDashboardAnalytics = async (range = {}) => {
const { params, whereClause } = buildDateFilter(range);
const [totalsResult, salesResult, revenueResult] = await Promise.all([
pool.query(`
SELECT
COALESCE(SUM(quantidade * valor_unitario), 0) as total_revenue,
COALESCE(SUM(quantidade), 0) as total_items,
COUNT(*)::int as order_line_count
FROM orders
${whereClause};
`, params),
pool.query(`
SELECT
COALESCE(${PRODUCT_NAME_SQL}, 'Unknown') as name,
MAX(produto_id) as id,
COALESCE(SUM(quantidade), 0) as value
FROM orders
${whereClause}
GROUP BY name
ORDER BY value DESC
LIMIT 10;
`, params),
pool.query(`
SELECT
COALESCE(${PRODUCT_NAME_SQL}, 'Unknown') as name,
MAX(produto_id) as id,
COALESCE(SUM(quantidade * valor_unitario), 0) as value
FROM orders
${whereClause}
GROUP BY name
ORDER BY value DESC
LIMIT 10;
`, params)
]);
const totals = totalsResult.rows[0] || {};
const orderLineCount = toNumber(totals.order_line_count);
const totalRevenue = toNumber(totals.total_revenue);
return {
range: {
start: normalizeDateParam(range.start),
end: normalizeDateParam(range.end)
},
totalRevenue,
totalOrders: toNumber(totals.total_items),
orderLineCount,
averageOrderValue: orderLineCount ? totalRevenue / orderLineCount : 0,
salesByProduct: salesResult.rows.map(row => ({
name: row.name,
id: row.id,
value: toNumber(row.value)
})),
revenueByProduct: revenueResult.rows.map(row => ({
name: row.name,
id: row.id,
value: toNumber(row.value)
}))
};
};
const getProductAnalytics = async (range = {}) => {
const { params, whereClause } = buildDateFilter(range);
const result = await pool.query(`
SELECT
COALESCE(${PRODUCT_NAME_SQL}, 'Unknown') as name,
MAX(produto_id) as id,
COALESCE(SUM(quantidade), 0) as quantity_sold,
COALESCE(SUM(quantidade * valor_unitario), 0) as revenue,
COUNT(*)::int as order_line_count,
MIN(data_pedido_date) as first_sale_date,
MAX(data_pedido_date) as last_sale_date
FROM orders
${whereClause}
GROUP BY name
ORDER BY revenue DESC, quantity_sold DESC
LIMIT 500;
`, params);
return result.rows.map(row => ({
name: row.name,
id: row.id,
quantitySold: toNumber(row.quantity_sold),
revenue: toNumber(row.revenue),
orderLineCount: toNumber(row.order_line_count),
firstSaleDate: row.first_sale_date,
lastSaleDate: row.last_sale_date
}));
};
const getClientAnalytics = async (range = {}) => {
const { params, whereClause } = buildDateFilter(range);
const result = await pool.query(`
SELECT
MAX(cliente_nome) as name,
cliente_fone as phone,
COALESCE(SUM(quantidade), 0) as quantity_purchased,
COALESCE(SUM(quantidade * valor_unitario), 0) as total_spent,
COUNT(*)::int as order_line_count,
MAX(data_pedido_date) as last_purchase_date
FROM orders
${whereClause}
AND cliente_fone IS NOT NULL
AND cliente_fone != ''
GROUP BY cliente_fone
ORDER BY total_spent DESC
LIMIT 500;
`, params);
return result.rows.map(row => ({
name: row.name,
phone: row.phone,
quantityPurchased: toNumber(row.quantity_purchased),
totalSpent: toNumber(row.total_spent),
orderLineCount: toNumber(row.order_line_count),
lastPurchaseDate: row.last_purchase_date
}));
};
module.exports = {
buildDateFilter,
getClientAnalytics,
getDashboardAnalytics,
getProductAnalytics,
normalizeDateParam
};

View File

@@ -0,0 +1,103 @@
const formatProductNameForDisplay = (name) => {
return String(name || '')
.toLocaleLowerCase('pt-BR')
.replace(/(^|[\s/-])(\p{L})/gu, (_, separator, letter) => {
return `${separator}${letter.toLocaleUpperCase('pt-BR')}`;
});
};
const formatProductList = (productNames) => {
const displayNames = productNames.map(formatProductNameForDisplay);
if (displayNames.length <= 2) {
return displayNames.join(' e ');
}
return `${displayNames.slice(0, -1).join(', ')} e ${displayNames[displayNames.length - 1]}`;
};
const groupCampaignRowsByBaseProduct = (rows) => {
return rows.reduce((acc, row) => {
if (!acc[row.base_product_name]) acc[row.base_product_name] = [];
acc[row.base_product_name].push(row);
return acc;
}, {});
};
const mapCampaignProducts = (groups) => {
return Object.entries(groups)
.sort(([, aItems], [, bItems]) => {
return new Date(aItems[0].created_at).getTime() - new Date(bItems[0].created_at).getTime();
})
.map(([baseProductName, items]) => {
const sortedItems = [...items].sort((a, b) => String(a.nome).localeCompare(String(b.nome), 'pt-BR'));
return {
baseProduct: formatProductNameForDisplay(baseProductName),
total_delta: sortedItems.reduce((sum, item) => sum + Number(item.delta_estoque || 0), 0),
sizes: sortedItems.map(item => ({
id: item.produto_id,
nome: item.nome,
delta: item.delta_estoque,
saldo: item.saldo
})),
itemIds: sortedItems.map(item => item.id)
};
});
};
const buildWhatsappCampaignPayload = (products, customers) => {
const productNames = products.map(product => product.baseProduct);
const productsText = formatProductList(productNames);
const allSizes = products.flatMap(product => product.sizes);
const totalDelta = products.reduce((sum, product) => sum + product.total_delta, 0);
return {
baseProduct: productsText,
productsText,
total_delta: totalDelta,
sizes: allSizes,
products: products.map(({ itemIds, ...product }) => product),
customers
};
};
const groupCampaignRows = (rows) => {
return Object.values(rows.reduce((acc, row) => {
const key = `${row.base_product_name}:${row.status}`;
if (!acc[key]) {
acc[key] = {
key,
baseProductName: row.base_product_name,
status: row.status,
totalDelta: 0,
rowCount: 0,
attempts: 0,
lastError: null,
createdAt: row.created_at,
updatedAt: row.updated_at,
sentAt: row.sent_at,
items: []
};
}
acc[key].totalDelta += Number(row.delta_estoque || 0);
acc[key].rowCount += 1;
acc[key].attempts = Math.max(acc[key].attempts, Number(row.attempts || 0));
acc[key].lastError = row.last_error || acc[key].lastError;
acc[key].createdAt = new Date(row.created_at) < new Date(acc[key].createdAt) ? row.created_at : acc[key].createdAt;
acc[key].updatedAt = new Date(row.updated_at) > new Date(acc[key].updatedAt) ? row.updated_at : acc[key].updatedAt;
acc[key].sentAt = row.sent_at || acc[key].sentAt;
acc[key].items.push(row);
return acc;
}, {})).sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
};
module.exports = {
buildWhatsappCampaignPayload,
formatProductNameForDisplay,
formatProductList,
groupCampaignRows,
groupCampaignRowsByBaseProduct,
mapCampaignProducts
};

View File

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

View File

@@ -0,0 +1,48 @@
const { pool } = require('../db');
const { formatOrderRow, normalizeOrderPayload } = require('../mappers/orderMapper');
const listOrders = async () => {
const result = await pool.query('SELECT * FROM orders ORDER BY id DESC');
return result.rows.map(formatOrderRow);
};
const upsertOrders = async (payload) => {
const client = await pool.connect();
try {
await client.query('BEGIN');
const insertQuery = `
INSERT INTO orders (
cliente_nome, data_pedido, data_pedido_date, valor_pedido,
produto_id, produto_descricao, quantidade, valor_unitario, pedido_id, cliente_fone
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
ON CONFLICT (pedido_id, produto_id) DO UPDATE SET
cliente_nome = EXCLUDED.cliente_nome,
data_pedido = EXCLUDED.data_pedido,
data_pedido_date = EXCLUDED.data_pedido_date,
valor_pedido = EXCLUDED.valor_pedido,
produto_descricao = EXCLUDED.produto_descricao,
quantidade = EXCLUDED.quantidade,
valor_unitario = EXCLUDED.valor_unitario,
cliente_fone = EXCLUDED.cliente_fone,
created_at = CURRENT_TIMESTAMP
`;
for (const item of payload) {
await client.query(insertQuery, normalizeOrderPayload(item));
}
await client.query('COMMIT');
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
};
module.exports = {
listOrders,
upsertOrders
};

View File

@@ -0,0 +1,56 @@
const { pool } = require('../db');
const { normalizeStockPayload } = require('../mappers/stockMapper');
const { enqueueStockCampaignItem } = require('./campaignService');
const POSITIVE_STOCK_DELTA_THRESHOLD = 1;
const listStock = async () => {
const result = await pool.query('SELECT * FROM stock');
return result.rows;
};
const upsertStockItems = async (payload) => {
const client = await pool.connect();
try {
await client.query('BEGIN');
const insertQuery = `
INSERT INTO stock (produto_id, nome, saldo, delta_estoque)
VALUES ($1, $2, $3, $4)
ON CONFLICT (produto_id) DO UPDATE SET
nome = EXCLUDED.nome,
saldo = EXCLUDED.saldo,
delta_estoque = EXCLUDED.delta_estoque,
updated_at = CURRENT_TIMESTAMP
`;
for (const rawItem of payload) {
const item = normalizeStockPayload(rawItem);
if (!item.produtoId) continue;
await client.query(insertQuery, [
item.produtoId,
item.nome,
item.saldo,
item.deltaEstoque
]);
if (item.deltaEstoque >= POSITIVE_STOCK_DELTA_THRESHOLD) {
await enqueueStockCampaignItem(client, item);
}
}
await client.query('COMMIT');
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
};
module.exports = {
listStock,
upsertStockItems
};

View File

@@ -0,0 +1,34 @@
const assert = require('node:assert/strict');
const test = require('node:test');
const { buildDateFilter, normalizeDateParam } = require('../services/analyticsService');
test('normalizeDateParam accepts strict ISO dates', () => {
assert.equal(normalizeDateParam('2026-05-28'), '2026-05-28');
});
test('normalizeDateParam rejects non-ISO or impossible dates', () => {
assert.equal(normalizeDateParam('28/05/2026'), null);
assert.equal(normalizeDateParam('2026-02-31'), null);
assert.equal(normalizeDateParam(''), null);
});
test('buildDateFilter builds bounded date predicates', () => {
const filter = buildDateFilter({ start: '2026-05-01', end: '2026-05-28' });
assert.deepEqual(filter.params, ['2026-05-01', '2026-05-28']);
assert.equal(
filter.whereClause,
'WHERE data_pedido_date IS NOT NULL AND data_pedido_date >= $1::date AND data_pedido_date <= $2::date'
);
});
test('buildDateFilter ignores invalid bounds', () => {
const filter = buildDateFilter({ start: 'invalid', end: '2026-05-28' });
assert.deepEqual(filter.params, ['2026-05-28']);
assert.equal(
filter.whereClause,
'WHERE data_pedido_date IS NOT NULL AND data_pedido_date <= $1::date'
);
});

View File

@@ -0,0 +1,112 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const {
buildWhatsappCampaignPayload,
formatProductList,
formatProductNameForDisplay,
groupCampaignRows,
groupCampaignRowsByBaseProduct,
mapCampaignProducts
} = require('../services/campaignFormatter');
const row = (overrides) => ({
id: 1,
base_product_name: 'BASE LISA CAMISETA COR BRANCO',
produto_id: 'SKU-1',
nome: 'BASE LISA CAMISETA COR BRANCO TAMANHO - P',
saldo: 10,
delta_estoque: 10,
status: 'pending',
attempts: 0,
last_error: null,
created_at: '2026-05-28T10:00:00.000Z',
updated_at: '2026-05-28T10:00:00.000Z',
sent_at: null,
...overrides
});
test('formatProductList uses Portuguese list joining', () => {
assert.equal(formatProductList([]), '');
assert.equal(formatProductList(['BONÉ - PRETO']), 'Boné - Preto');
assert.equal(formatProductList(['BONÉ - PRETO', 'BASE BRANCA']), 'Boné - Preto e Base Branca');
assert.equal(
formatProductList(['BONÉ - PRETO', 'BASE BRANCA', 'BASE PRETA']),
'Boné - Preto, Base Branca e Base Preta'
);
});
test('formatProductNameForDisplay converts campaign product names to title case', () => {
assert.equal(
formatProductNameForDisplay('BASE LISA MOLETOM CANGURU COR PRETO'),
'Base Lisa Moletom Canguru Cor Preto'
);
assert.equal(formatProductNameForDisplay('BONÉ - BRANCO'), 'Boné - Branco');
});
test('mapCampaignProducts accumulates split deltas by base product', () => {
const groups = groupCampaignRowsByBaseProduct([
row({ id: 1, delta_estoque: 10, produto_id: 'SKU-P', nome: 'Produto Split TAMANHO - P' }),
row({ id: 2, delta_estoque: 50, produto_id: 'SKU-M', nome: 'Produto Split TAMANHO - M' }),
row({ id: 3, delta_estoque: 40, produto_id: 'SKU-G', nome: 'Produto Split TAMANHO - G' })
]);
const products = mapCampaignProducts(groups);
assert.equal(products.length, 1);
assert.equal(products[0].total_delta, 100);
assert.deepEqual(products[0].itemIds, [3, 2, 1]);
assert.deepEqual(products[0].sizes.map(size => size.id), ['SKU-G', 'SKU-M', 'SKU-P']);
});
test('buildWhatsappCampaignPayload combines multiple ready products into one message payload', () => {
const products = mapCampaignProducts(groupCampaignRowsByBaseProduct([
row({
id: 1,
base_product_name: 'BONÉ - PRETO',
produto_id: 'BONE-P',
nome: 'BONÉ - PRETO TAMANHO - P',
delta_estoque: 100,
created_at: '2026-05-28T10:00:00.000Z'
}),
row({
id: 2,
base_product_name: 'BASE LISA CAMISETA COR BRANCO',
produto_id: 'BASE-P',
nome: 'BASE LISA CAMISETA COR BRANCO TAMANHO - P',
delta_estoque: 100,
created_at: '2026-05-28T11:00:00.000Z'
})
]));
const payload = buildWhatsappCampaignPayload(products, [{ nome: 'Cliente', fone: '5511999999999' }]);
assert.equal(payload.productsText, 'Boné - Preto e Base Lisa Camiseta Cor Branco');
assert.equal(payload.baseProduct, payload.productsText);
assert.equal(payload.total_delta, 200);
assert.equal(payload.products.length, 2);
assert.equal(payload.sizes.length, 2);
assert.deepEqual(Object.keys(payload.products[0]).includes('itemIds'), false);
});
test('groupCampaignRows summarizes rows by base product and status', () => {
const groups = groupCampaignRows([
row({ id: 1, delta_estoque: 60, attempts: 1, updated_at: '2026-05-28T10:00:00.000Z' }),
row({ id: 2, delta_estoque: 40, attempts: 2, updated_at: '2026-05-28T10:05:00.000Z' }),
row({
id: 3,
base_product_name: 'BONÉ - PRETO',
status: 'failed',
delta_estoque: 100,
attempts: 3,
last_error: 'Webhook failed',
updated_at: '2026-05-28T11:00:00.000Z'
})
]);
assert.equal(groups.length, 2);
assert.equal(groups[0].baseProductName, 'BONÉ - PRETO');
assert.equal(groups[0].lastError, 'Webhook failed');
assert.equal(groups[1].baseProductName, 'BASE LISA CAMISETA COR BRANCO');
assert.equal(groups[1].totalDelta, 100);
assert.equal(groups[1].rowCount, 2);
assert.equal(groups[1].attempts, 2);
});

View File

@@ -0,0 +1,37 @@
const assert = require('node:assert/strict');
const test = require('node:test');
const { normalizeOrderDate, normalizeOrderPayload } = require('../mappers/orderMapper');
test('normalizeOrderDate accepts Brazilian display dates', () => {
assert.equal(normalizeOrderDate('28/05/2026'), '2026-05-28');
assert.equal(normalizeOrderDate('28-05-2026'), '2026-05-28');
});
test('normalizeOrderDate accepts ISO-like dates', () => {
assert.equal(normalizeOrderDate('2026-05-28'), '2026-05-28');
assert.equal(normalizeOrderDate('2026/05/28 10:30:00'), '2026-05-28');
});
test('normalizeOrderDate rejects invalid dates', () => {
assert.equal(normalizeOrderDate(''), null);
assert.equal(normalizeOrderDate('not a date'), null);
assert.equal(normalizeOrderDate('31/02/2026'), null);
});
test('normalizeOrderPayload includes normalized date without changing display date', () => {
const payload = normalizeOrderPayload({
Nome_Cliente: 'Cliente Teste',
Data_Pedido: '28/05/2026',
Valor_Pedido: '120.50',
ID_Produto: 'SKU-1',
Descricao_Produto: 'Produto',
Quantidade: '2',
Valor_Unitario: '60.25',
ID_Pedido: 'ORDER-1',
Fone_Cliente: '(16) 99999-9999'
});
assert.equal(payload[1], '28/05/2026');
assert.equal(payload[2], '2026-05-28');
});

View File

@@ -0,0 +1,29 @@
const assert = require('node:assert/strict');
const test = require('node:test');
const { getBaseProductName } = require('../mappers/stockMapper');
test('getBaseProductName strips TAMANHO suffixes', () => {
assert.equal(
getBaseProductName('BASE LISA CAMISETA COR BRANCO TAMANHO - P'),
'BASE LISA CAMISETA COR BRANCO'
);
});
test('getBaseProductName strips trailing size suffixes without removing colors', () => {
assert.equal(
getBaseProductName('BASE LISA MOLETOM CANGURU COR PRETO - M'),
'BASE LISA MOLETOM CANGURU COR PRETO'
);
assert.equal(
getBaseProductName('BASE LISA MOLETOM CANGURU COR PRETO - M/G/GG'),
'BASE LISA MOLETOM CANGURU COR PRETO'
);
assert.equal(getBaseProductName('BONÉ - BRANCO'), 'BONÉ - BRANCO');
});
test('getBaseProductName preserves etiqueta product variants', () => {
assert.equal(getBaseProductName('ETIQUETA 10X5 851UN'), 'ETIQUETA 10X5 851UN');
assert.equal(getBaseProductName('ETIQUETA BRANCA TAMANHO 08'), 'ETIQUETA BRANCA TAMANHO 08');
assert.equal(getBaseProductName('ETIQUETA BRANCA TAMANHO GG'), 'ETIQUETA BRANCA TAMANHO GG');
});

View File

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

View File

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

View File

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

View File

@@ -1,14 +1,17 @@
import React from 'react'; import React, { Suspense } from 'react';
import { Routes, Route, Navigate, useLocation } from 'react-router-dom'; import { Routes, Route, Navigate, useLocation } from 'react-router-dom';
import { Loader2 } from 'lucide-react';
import Layout from './components/Layout'; import Layout from './components/Layout';
import Dashboard from './pages/Dashboard';
import Products from './pages/Products';
import ProductDetails from './pages/ProductDetails';
import Clients from './pages/Clients';
import ClientDetails from './pages/ClientDetails';
import Login from './pages/Login';
import { isAuthenticated } from './dataService'; import { isAuthenticated } from './dataService';
const Dashboard = React.lazy(() => import('./pages/Dashboard'));
const Products = React.lazy(() => import('./pages/Products'));
const ProductDetails = React.lazy(() => import('./pages/ProductDetails'));
const Clients = React.lazy(() => import('./pages/Clients'));
const ClientDetails = React.lazy(() => import('./pages/ClientDetails'));
const Campaigns = React.lazy(() => import('./pages/Campaigns'));
const Login = React.lazy(() => import('./pages/Login'));
function PrivateRoute({ children }: { children: React.ReactNode }) { function PrivateRoute({ children }: { children: React.ReactNode }) {
const location = useLocation(); const location = useLocation();
if (!isAuthenticated()) { if (!isAuthenticated()) {
@@ -17,8 +20,15 @@ function PrivateRoute({ children }: { children: React.ReactNode }) {
return children; return children;
} }
const RouteFallback = () => (
<div className="flex min-h-screen items-center justify-center bg-dark-bg text-brand-primary">
<Loader2 className="h-8 w-8 animate-spin" />
</div>
);
function App() { function App() {
return ( return (
<Suspense fallback={<RouteFallback />}>
<Routes> <Routes>
<Route path="/login" element={<Login />} /> <Route path="/login" element={<Login />} />
<Route path="/" element={<Navigate to="/graph" replace />} /> <Route path="/" element={<Navigate to="/graph" replace />} />
@@ -28,8 +38,10 @@ function App() {
<Route path="products/:id" element={<ProductDetails />} /> <Route path="products/:id" element={<ProductDetails />} />
<Route path="clients" element={<Clients />} /> <Route path="clients" element={<Clients />} />
<Route path="clients/:name" element={<ClientDetails />} /> <Route path="clients/:name" element={<ClientDetails />} />
<Route path="campaigns" element={<Campaigns />} />
</Route> </Route>
</Routes> </Routes>
</Suspense>
); );
} }

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

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

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

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

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

@@ -0,0 +1,30 @@
import type { DateRange, OrderData } from '../types';
import { parseOrderDate } from '../dataService';
const SIZE_SUFFIX_PATTERN = /\s+-\s+(?:(?:PP|P|M|G|GG|XG|XGG|EG|EGG|EXG|U|UNICO|ÚNICO|\d{2})(?:\/(?:PP|P|M|G|GG|XG|XGG|EG|EGG|EXG|U|UNICO|ÚNICO|\d{2}))*)$/i;
export const getBaseProductName = (description: string): string => {
const productName = description.trim();
if (productName.toLocaleUpperCase('pt-BR').startsWith('ETIQUETA')) {
return productName;
}
return productName.split(' TAMANHO')[0].replace(SIZE_SUFFIX_PATTERN, '').trim();
};
export const getClientDisplayName = (order: OrderData): string => {
return order.Nome_Cliente || `Cliente Desconhecido (Pedido ${order.Valor_Pedido})`;
};
export const isOrderInDateRange = (order: OrderData, dateRange: DateRange): boolean => {
const orderDate = parseOrderDate(order.Data_Pedido);
return orderDate >= dateRange.start && orderDate <= dateRange.end;
};
export const filterOrdersByDateRange = (orders: OrderData[], dateRange: DateRange): OrderData[] => {
return orders.filter(order => isOrderInDateRange(order, dateRange));
};
export const getOrderItemRevenue = (order: OrderData): number => {
return order.Quantidade * order.Valor_Unitario;
};

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

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

View File

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

Before

Width:  |  Height:  |  Size: 4.0 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 8.5 KiB

View File

@@ -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,45 +76,110 @@ 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">
{/* Date Presets Dropdown */}
<div className="relative">
<button
onClick={() => setIsPresetOpen(!isPresetOpen)}
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"
>
<Calendar size={16} className="text-dark-muted shrink-0" /> <Calendar size={16} className="text-dark-muted shrink-0" />
<div className="flex items-center gap-2 text-sm font-medium text-dark-text"> <span>{formatShortDate(dateRange.start)} - {formatShortDate(dateRange.end)}</span>
<ChevronDown size={14} className="text-dark-muted ml-1" />
</button>
{isPresetOpen && (
<>
<div className="fixed inset-0 z-10" onClick={() => setIsPresetOpen(false)}></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">
<div className="px-4 pb-3 pt-1 border-b border-dark-border mb-2 flex flex-col gap-2">
<span className="text-xs font-bold text-dark-muted uppercase tracking-widest">Período Customizado</span>
<div className="flex flex-col gap-1.5">
<div className="flex items-center justify-between gap-2">
<span className="text-xs font-medium text-dark-text w-6">De:</span>
<div <div
className="relative cursor-pointer hover:text-brand-primary transition-colors" 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(startRef)} onClick={() => openPicker(startRef)}
> >
{formatShortDate(dateRange.start)} <span className="w-full text-center">{formatShortDate(dateRange.start)}</span>
<input <input
ref={startRef} ref={startRef}
type="date" type="date"
value={formatDateForInput(dateRange.start)} value={formatDateForInput(dateRange.start)}
onChange={handleStartChange} onChange={handleStartChange}
className="absolute opacity-0 w-0 h-0 overflow-hidden" className="absolute inset-0 opacity-0 cursor-pointer"
/> />
</div> </div>
<span className="text-dark-muted font-normal text-xs">até</span> </div>
<div className="flex items-center justify-between gap-2">
<span className="text-xs font-medium text-dark-text w-6">Até:</span>
<div <div
className="relative cursor-pointer hover:text-brand-primary transition-colors" 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)} onClick={() => openPicker(endRef)}
> >
{formatShortDate(dateRange.end)} <span className="w-full text-center">{formatShortDate(dateRange.end)}</span>
<input <input
ref={endRef} ref={endRef}
type="date" type="date"
value={formatDateForInput(dateRange.end)} value={formatDateForInput(dateRange.end)}
onChange={handleEndChange} onChange={handleEndChange}
className="absolute opacity-0 w-0 h-0 overflow-hidden" className="absolute inset-0 opacity-0 cursor-pointer"
/> />
</div> </div>
</div> </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>
{/* 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>
); );
}; };

View File

@@ -1,11 +1,12 @@
import { useState, useEffect } from 'react'; import { useCallback, useState, useEffect } from 'react';
import { Outlet, Link, useLocation } from 'react-router-dom'; import { Outlet, Link, useLocation } from 'react-router-dom';
import { LayoutDashboard, Users, BarChart3, ChevronLeft, ChevronRight, Package, Loader2, LogOut } from 'lucide-react'; import { LayoutDashboard, Users, BarChart3, ChevronLeft, ChevronRight, Package, Loader2, LogOut, Megaphone } from 'lucide-react';
import type { DateRange, OrderData } from '../types'; import type { DateRange, OrderData, StockData } from '../types';
import { fetchData, logout } from '../dataService'; import { fetchData, fetchStock, logout } from '../dataService';
const Layout = () => { const Layout = () => {
const location = useLocation(); const location = useLocation();
const needsRawData = location.pathname.startsWith('/products') || location.pathname.startsWith('/clients');
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(() => { const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(() => {
return localStorage.getItem('graph_sidebar_collapsed') === 'true'; return localStorage.getItem('graph_sidebar_collapsed') === 'true';
}); });
@@ -25,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" />
Atualizando dados
</div> </div>
) : (
<Outlet context={{ dateRange, setDateRange, ordersData }} />
)} )}
<Outlet context={{ dateRange, setDateRange, ordersData, stockData, isDataLoading: needsRawData && isLoading, refreshInterval, setRefreshInterval, loadData }} />
</div> </div>
</main> </main>
</div> </div>

View File

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

View File

@@ -1,4 +1,4 @@
import type { OrderData } from './types'; import type { CampaignPreview, CampaignProcessSummary, CampaignQueueSummary, DashboardAnalytics, DateRange, OrderData, StockData } from './types';
const API_URL = import.meta.env.VITE_API_URL || '/api'; const API_URL = import.meta.env.VITE_API_URL || '/api';
@@ -33,6 +33,25 @@ export const isAuthenticated = (): boolean => {
return !!localStorage.getItem('auth_token'); return !!localStorage.getItem('auth_token');
}; };
export const fetchStock = async (): Promise<StockData[]> => {
try {
const token = localStorage.getItem('auth_token');
const response = await fetch(`${API_URL}/stock`, {
headers: {
'Authorization': `Bearer ${token}`
}
});
if (response.status === 401 || response.status === 403) {
logout();
return [];
}
if (!response.ok) return [];
return await response.json();
} catch {
return [];
}
};
export const fetchData = async (): Promise<OrderData[]> => { export const fetchData = async (): Promise<OrderData[]> => {
try { try {
const token = localStorage.getItem('auth_token'); const token = localStorage.getItem('auth_token');
@@ -53,6 +72,96 @@ export const fetchData = async (): Promise<OrderData[]> => {
} }
}; };
const authFetch = async (path: string, options: RequestInit = {}): Promise<Response> => {
const token = localStorage.getItem('auth_token');
const response = await fetch(`${API_URL}${path}`, {
...options,
headers: {
...(options.headers || {}),
'Authorization': `Bearer ${token}`
}
});
if (response.status === 401 || response.status === 403) {
logout();
}
return response;
};
const formatDateParam = (date: Date): string => {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
};
export const fetchDashboardAnalytics = async (dateRange: DateRange): Promise<DashboardAnalytics | null> => {
try {
const params = new URLSearchParams({
start: formatDateParam(dateRange.start),
end: formatDateParam(dateRange.end)
});
const response = await authFetch(`/analytics/dashboard?${params.toString()}`);
if (!response.ok) return null;
return await response.json();
} catch (error) {
console.error('Fetch dashboard analytics failed', error);
return null;
}
};
export const fetchCampaigns = async (): Promise<CampaignQueueSummary | null> => {
try {
const response = await authFetch('/campaigns');
if (!response.ok) return null;
return await response.json();
} catch (error) {
console.error('Fetch campaigns failed', error);
return null;
}
};
export const fetchCampaignPreview = async (): Promise<CampaignPreview | null> => {
try {
const response = await authFetch('/campaigns/preview');
if (!response.ok) return null;
return await response.json();
} catch (error) {
console.error('Fetch campaign preview failed', error);
return null;
}
};
export const processCampaignsNow = async (): Promise<CampaignProcessSummary | null> => {
try {
const response = await authFetch('/campaigns/process', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({})
});
if (!response.ok) return null;
return await response.json();
} catch (error) {
console.error('Process campaigns failed', error);
return null;
}
};
export const retryCampaignGroup = async (baseProductName: string): Promise<boolean> => {
try {
const response = await authFetch('/campaigns/retry', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ baseProductName })
});
return response.ok;
} catch (error) {
console.error('Retry campaign failed', error);
return false;
}
};
export const parseOrderDate = (dateStr: string): Date => { export const parseOrderDate = (dateStr: string): Date => {
if (!dateStr) return new Date(0); if (!dateStr) return new Date(0);
if (dateStr.includes('T')) return new Date(dateStr); if (dateStr.includes('T')) return new Date(dateStr);
@@ -69,3 +178,36 @@ export const parseOrderDate = (dateStr: string): Date => {
const fallback = new Date(dateStr); const fallback = new Date(dateStr);
return isNaN(fallback.getTime()) ? new Date(0) : fallback; 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);
}
};

View File

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

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

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

View File

@@ -1,54 +1,30 @@
import { useMemo } from 'react'; import { useMemo } from 'react';
import { useParams, Link, useOutletContext } from 'react-router-dom'; import { useParams, Link, useOutletContext } from 'react-router-dom';
import { ArrowLeft, User, Tag, Package, DollarSign } 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>
<div className="flex items-center gap-3 mt-1">
<p className="text-zinc-500 dark:text-dark-muted font-medium">Histórico completo de compras</p> <p className="text-zinc-500 dark:text-dark-muted font-medium">Histórico completo de compras</p>
{clientPhone && (
<>
<span className="text-zinc-300 dark:text-dark-border"></span>
<span className="flex items-center gap-1.5 text-brand-primary font-bold text-sm bg-brand-primary/10 px-2 py-1 rounded-md">
<Phone className="w-3.5 h-3.5" />
{clientPhone}
</span>
</>
)}
</div>
</div> </div>
</div> </div>
@@ -90,14 +77,14 @@ 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"> <h2 className="text-sm font-bold uppercase tracking-wider text-zinc-500 dark:text-dark-muted flex items-center gap-2">
Data do Pedido: {group.date} <Tag className="w-4 h-4 text-brand-primary" />
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)}
@@ -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>

View File

@@ -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 { parseOrderDate } from '../dataService'; import { exportToCSV } from '../dataService';
import DateRangePicker from '../components/DateRangePicker';
type SortOption = 'recent' | 'spent_desc' | 'spent_asc' | 'items_desc' | 'items_asc'; import { buildClientsSummary, type ClientSortOption } from '../analytics/clients';
const Clients = () => { const 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 orderTime = parseOrderDate(order.Data_Pedido).getTime();
if (orderTime > clientMap[clientName].lastPurchase) {
clientMap[clientName].lastPurchase = orderTime;
}
});
let result = Object.keys(clientMap).map(name => ({
name,
totalSpent: clientMap[name].totalSpent,
totalItems: clientMap[name].totalItems,
orderCount: clientMap[name].uniqueOrders.size, // Grouped by unique date+value combinations
lastPurchase: clientMap[name].lastPurchase
}));
if (searchTerm) {
result = result.filter(c => c.name.toLowerCase().includes(searchTerm.toLowerCase()));
}
return result.sort((a, b) => {
switch (sortBy) {
case 'recent': return b.lastPurchase - a.lastPurchase;
case 'spent_desc': return b.totalSpent - a.totalSpent;
case 'spent_asc': return a.totalSpent - b.totalSpent;
case 'items_desc': return b.totalItems - a.totalItems;
case 'items_asc': return a.totalItems - b.totalItems;
default: return 0;
}
});
}, [searchTerm, sortBy, ordersData]);
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>
); );

View File

@@ -1,55 +1,92 @@
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 { parseOrderDate } from '../dataService'; import { applyDashboardColors, buildDashboardMetrics } from '../analytics/dashboard';
import { fetchDashboardAnalytics } from '../dataService';
const COLORS = [
'#10b981', '#3b82f6', '#8b5cf6', '#f43f5e', '#f97316',
'#06b6d4', '#ec4899', '#eab308', '#6366f1', '#14b8a6'
];
const Dashboard = () => {
const { dateRange, setDateRange, ordersData } = useOutletContext<{ dateRange: DateRange, setDateRange: (range: DateRange) => void, ordersData: OrderData[] }>();
const filteredData = useMemo(() => {
const orders = ordersData;
return orders.filter(order => {
const orderDate = parseOrderDate(order.Data_Pedido);
return orderDate >= dateRange.start && orderDate <= dateRange.end;
});
}, [dateRange, ordersData]);
const { totalRevenue, totalOrders, averageOrderValue, salesByProduct } = useMemo(() => {
let revenue = 0;
let totalItems = 0;
const productSalesMap: Record<string, number> = {};
filteredData.forEach(order => {
revenue += (order.Quantidade * order.Valor_Unitario);
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 => ({
name: key,
value: productSalesMap[key]
})).sort((a, b) => b.value - a.value).slice(0, 10);
return { totalRevenue: revenue, totalOrders: totalItems, averageOrderValue: revenue / (filteredData.length || 1), salesByProduct: productsData };
}, [filteredData]);
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);
}; };
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 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 loadDashboardMetrics = useCallback(async (range: DateRange) => {
setIsMetricsLoading(true);
const metrics = await fetchDashboardAnalytics(range);
setServerMetrics(metrics);
setIsMetricsLoading(false);
}, []);
useEffect(() => {
// Dashboard metrics are synchronized with the selected server-side date range.
// eslint-disable-next-line react-hooks/set-state-in-effect
void loadDashboardMetrics(dateRange);
}, [dateRange, loadDashboardMetrics]);
useEffect(() => {
if (refreshInterval === 0) return;
const intervalId = setInterval(() => {
void loadDashboardMetrics(dateRange);
}, refreshInterval);
return () => clearInterval(intervalId);
}, [dateRange, loadDashboardMetrics, refreshInterval]);
const { totalRevenue, totalOrders, averageOrderValue, salesByProduct, revenueByProduct } = useMemo(() => {
if (serverMetrics) return applyDashboardColors(serverMetrics);
return buildDashboardMetrics(ordersData, dateRange);
}, [dateRange, ordersData, serverMetrics]);
const handleManualRefresh = () => {
void loadDashboardMetrics(dateRange);
};
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 md:flex-row md:items-center justify-between gap-4">
@@ -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>
))} ))}

View File

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

View File

@@ -4,53 +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 { parseOrderDate } from '../dataService'; 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 orderDate = parseOrderDate(order.Data_Pedido);
if (orderDate >= dateRange.start && orderDate <= dateRange.end) { const dateStr = order.Data_Pedido;
salesByDate[dateStr] = (salesByDate[dateStr] || 0) + order.Quantidade;
sold += order.Quantidade;
revenue += (order.Quantidade * order.Valor_Unitario);
}
});
const chart = Object.keys(salesByDate).map(date => ({
date,
value: salesByDate[date]
})).sort((a, b) => {
const [da, ma, ya] = a.date.split('-').map(Number);
const [db, mb, yb] = b.date.split('-').map(Number);
return new Date(ya, ma - 1, da).getTime() - new Date(yb, mb - 1, db).getTime();
});
return { productInfo: info, chartData: chart, totalSold: sold, totalRevenue: revenue };
}, [id, dateRange, ordersData]); }, [id, dateRange, ordersData]);
const formatCurrency = (value: number) => { const formatCurrency = (value: number) => {
return new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(value); return new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(value);
}; };
if (!productInfo && isDataLoading) {
return (
<div className="text-center py-12">
<p className="text-zinc-500 dark:text-dark-muted font-medium">Carregando produto...</p>
</div>
);
}
if (!productInfo) { if (!productInfo) {
return ( return (
<div className="text-center py-12"> <div className="text-center py-12">
@@ -80,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">
@@ -106,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>

View File

@@ -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 { parseOrderDate } from '../dataService'; 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 orderDate = parseOrderDate(order.Data_Pedido); const totalPages = Math.ceil(productsData.length / itemsPerPage);
if (orderDate < dateRange.start || orderDate > dateRange.end) return; const startIndex = (currentPage - 1) * itemsPerPage;
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>
); );

View File

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

View File

@@ -3,8 +3,27 @@ import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite' import tailwindcss from '@tailwindcss/vite'
// https://vite.dev/config/ // https://vite.dev/config/
export default defineConfig({ export default defineConfig(({ command }) => ({
plugins: [react(), tailwindcss()], plugins: [
command === 'serve' && {
name: 'react-refresh-preamble-fix',
transformIndexHtml() {
return [{
tag: 'script',
attrs: { type: 'module' },
children: `
import RefreshRuntime from "/@react-refresh";
RefreshRuntime.injectIntoGlobalHook(window);
window.$RefreshReg$ = () => {};
window.$RefreshSig$ = () => (type) => type;
window.__vite_plugin_react_preamble_installed__ = true;
`
}]
}
},
react(),
tailwindcss()
],
server: { server: {
port: 3001, port: 3001,
proxy: { proxy: {
@@ -23,4 +42,4 @@ export default defineConfig({
} }
} }
} }
}) }))