Compare commits

...

60 Commits

Author SHA1 Message Date
Cauê Faleiros
0e06e800ec Match seller list loading state to table
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m1s
2026-08-04 10:59:51 -03:00
Cauê Faleiros
7d89c134cb Match seller detail loading state to page
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 53s
2026-08-04 10:48:02 -03:00
Cauê Faleiros
2498437f25 Paginate seller revenue trend
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 44s
2026-08-04 10:30:50 -03:00
Cauê Faleiros
e0fe15c3dc Use detail icons in client and seller tables
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 47s
2026-08-04 10:18:23 -03:00
Cauê Faleiros
7831d13c7e Align seller table with client design
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 45s
2026-08-04 10:13:04 -03:00
Cauê Faleiros
ff0f73e87c Add seller analytics pages
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m47s
2026-08-04 09:59:49 -03:00
Cauê Faleiros
f18e7c4845 Unify supply service and mapping queues
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m31s
2026-08-03 14:42:14 -03:00
Cauê Faleiros
2d738073bf Make app navigation mobile usable
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 47s
2026-08-03 13:39:21 -03:00
Cauê Faleiros
59fe9104d6 Redesign purchase needs workflow
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 53s
2026-08-03 13:16:46 -03:00
Cauê Faleiros
f69efb5358 Clarify supply execution states
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m47s
2026-08-03 12:57:43 -03:00
Cauê Faleiros
0ff267be2c Simplify supply hub guidance
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m30s
2026-08-03 11:57:49 -03:00
Cauê Faleiros
7939a6b078 Prioritize supply operations dashboard
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 46s
2026-08-03 11:52:30 -03:00
Cauê Faleiros
fb8b827c73 Scroll product details to top on navigation
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 51s
2026-08-03 11:36:24 -03:00
Cauê Faleiros
60ded40595 Highlight current product variation
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 51s
2026-08-03 11:29:49 -03:00
Cauê Faleiros
42cee3f505 Paginate product variations
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m0s
2026-08-03 11:19:01 -03:00
Cauê Faleiros
ef3bf4ce5c Move composition below product volume
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 52s
2026-08-03 11:13:22 -03:00
Cauê Faleiros
6d0d8e6cf0 Simplify product composition details
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m14s
2026-08-03 11:09:00 -03:00
Cauê Faleiros
635103b924 Refine material planning workflows
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m0s
2026-08-03 10:57:47 -03:00
Cauê Faleiros
7fcf52cf67 Remove data health dashboard
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 45s
2026-08-03 10:31:23 -03:00
Cauê Faleiros
e993bfdaf3 Use Tiny compositions for purchase planning
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m41s
2026-08-03 10:22:39 -03:00
Cauê Faleiros
1a3fed3dda Update project context after composition import 2026-08-03 09:58:37 -03:00
Cauê Faleiros
37d4a7732e Import Tiny product compositions 2026-07-31 11:32:49 -03:00
Cauê Faleiros
b431802d50 Add product composition export
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m16s
2026-07-31 10:30:49 -03:00
Cauê Faleiros
5559e9951a Support Tiny Olist V3 product compositions
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m57s
2026-07-29 15:28:00 -03:00
Cauê Faleiros
9c7a383ee2 Fix duplicate top campaign clients by phone
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m22s
2026-07-29 10:44:22 -03:00
Cauê Faleiros
496f9d432d Deduplicate top campaign clients by name
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m54s
2026-07-29 09:54:22 -03:00
Cauê Faleiros
9a7e0d6818 Normalize Turnstile env values
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 48s
2026-07-28 13:37:37 -03:00
Cauê Faleiros
6afd0c0392 Guard invalid Turnstile site key
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 46s
2026-07-28 13:24:48 -03:00
Cauê Faleiros
c741e520ea Support Portainer Turnstile env aliases
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 50s
2026-07-28 13:10:13 -03:00
Cauê Faleiros
ed3a3f0571 Load Turnstile site key at runtime
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 54s
2026-07-28 13:03:51 -03:00
Cauê Faleiros
20dde11ff0 Improve Turnstile login failure message
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 47s
2026-07-28 12:56:17 -03:00
Cauê Faleiros
4857438e6f Reapply "Use Cloudflare Turnstile secret env name"
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m35s
This reverts commit 302b55b346.
2026-07-28 12:42:27 -03:00
Cauê Faleiros
252c2447a8 Reapply "Add Turnstile captcha to login"
This reverts commit a7c732e139.
2026-07-28 12:42:27 -03:00
Cauê Faleiros
a7c732e139 Revert "Add Turnstile captcha to login"
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 52s
This reverts commit 5aa1be27bb.
2026-07-28 11:52:46 -03:00
Cauê Faleiros
302b55b346 Revert "Use Cloudflare Turnstile secret env name"
This reverts commit e8eafaf9b9.
2026-07-28 11:52:46 -03:00
Cauê Faleiros
e8eafaf9b9 Use Cloudflare Turnstile secret env name
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 49s
2026-07-28 11:46:26 -03:00
Cauê Faleiros
5aa1be27bb Add Turnstile captcha to login
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m57s
2026-07-28 11:34:47 -03:00
Cauê Faleiros
5c9ab6bb11 Add top campaign clients endpoint
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m39s
2026-07-27 14:39:44 -03:00
Cauê Faleiros
800eb976ab Derive consumption references from Tiny OP sync
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 3m16s
2026-07-23 11:53:18 -03:00
Cauê Faleiros
c07938c94e Add Tiny production order detail sync 2026-07-23 10:42:53 -03:00
Cauê Faleiros
615e07ad32 Allow DTF in campaigns
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m40s
2026-07-22 11:27:15 -03:00
Cauê Faleiros
c07264fd69 Filter campaign products to apparel 2026-07-22 11:22:49 -03:00
Cauê Faleiros
9c77475ce5 Seed default catalog categories 2026-07-22 09:21:21 -03:00
Cauê Faleiros
eaa0a5010d Clarify data cleanup ownership
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 46s
2026-07-21 15:02:30 -03:00
Cauê Faleiros
a6e07e46d9 Align purchase needs pending column
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m25s
2026-07-21 14:22:17 -03:00
Cauê Faleiros
166f97c629 Separate purchase needs status styling 2026-07-21 14:16:17 -03:00
Cauê Faleiros
98d278947b Remove purchase needs warning copy 2026-07-21 14:10:22 -03:00
Cauê Faleiros
c7536d9097 Tighten purchase needs setup table 2026-07-21 14:02:12 -03:00
Cauê Faleiros
b4e51eb8eb Refine purchase needs setup queue 2026-07-21 13:56:10 -03:00
Cauê Faleiros
51b73ed82b Add database diagnostic export
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m29s
2026-07-21 13:20:48 -03:00
Cauê Faleiros
eb5cc01d56 Paginate stock inventory tab
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m25s
2026-07-21 11:30:00 -03:00
Cauê Faleiros
4c3df482be Match fabric planning card heights 2026-07-21 11:15:41 -03:00
Cauê Faleiros
377754c26e Move fabric plan form to right side 2026-07-21 11:02:29 -03:00
Cauê Faleiros
bc74a1089b Show Tiny stock in inventory control
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m14s
2026-07-21 10:46:24 -03:00
Cauê Faleiros
f992517891 Reposition fabric planning form 2026-07-21 10:11:23 -03:00
Cauê Faleiros
6283f86091 Simplify cutting page workflow views
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m44s
2026-07-20 15:16:03 -03:00
Cauê Faleiros
e1f001b57d Add production order generation preview
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m20s
2026-07-20 14:12:55 -03:00
Cauê Faleiros
88089343a8 Add local production order workflow
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m40s
2026-07-20 12:57:28 -03:00
Cauê Faleiros
9af5f296a7 Improve planning data normalization
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m25s
2026-07-20 11:24:47 -03:00
Cauê Faleiros
3066ba06a2 Refine planning product classification
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m15s
2026-07-20 10:16:58 -03:00
48 changed files with 5381 additions and 514 deletions

View File

@@ -20,6 +20,15 @@ ADMIN_EMAIL=admin@admin.com
ADMIN_PASSWORD=admin123
JWT_SECRET=super_secret_jwt_key_123
# --- CAPTCHA / Bot Protection (Optional) ---
# Create keys in Cloudflare Turnstile and set both values in production.
# When TURNSTILE_SECRET is empty, backend CAPTCHA enforcement is disabled.
TURNSTILE_SITE_KEY=
TURNSTILE_SECRET=
# Backward-compatible aliases also accepted by the backend:
# VITE_TURNSTILE_SITE_KEY=
# TURNSTILE_SECRET_KEY=
# --- Frontend Configuration (Optional) ---
# If you need to override the API URL for the frontend
# VITE_API_URL=/api

View File

@@ -38,6 +38,7 @@ This project (often referred to as "Nexstar Graphs" or simply "Graphs") is a rea
* **Route Code Splitting:** Frontend routes are lazy-loaded with `React.lazy`/`Suspense` so the first JS payload stays smaller. Recharts-heavy page chunks are loaded on demand.
* **Analytics Client Cache:** Frontend analytics requests use a small shared cache/deduping layer in `src/dataService.ts`. Navigating between Dashboard, RFV, Products, and Clients should reuse fresh responses instead of refetching every route mount unless query params change or the cache expires.
* **Tiny as Current Source, Graphs as Future Operating Layer:** Tiny ERP is the current source for sales, product/order metadata, and stock. Graphs should increasingly become the place where the business manages production intelligence: cut configuration, SKU families, raw material relationships, consumption references, and operational charts. `NECESSIDADE DE CORTE.xlsx` is reference material only and must not become a runtime dependency.
* **Tiny/Olist Composition Import:** Product structures from Tiny/Olist V3 are imported into `product_compositions` and `product_composition_components`. Sellable finished-product structures are also promoted into local catalog products/materials and `consumption_references` with source `tiny_structure`, so purchase planning can use real component consumption instead of guessed kg-per-piece rules.
## 4. Directory Structure
```text
@@ -111,16 +112,35 @@ yield: 4.8 units/kg
* **Finished SKU catalog:** SKU/id, name, color, size, product family, cut family, active/inactive.
* **Raw material catalog:** material SKU/id, name, type (`malha`, `ribana`, `fio`, etc.), color, supplier, unit (`kg`, `metro`, `unidade`), and whether it is raw input instead of finished stock.
* **Consumption references:** finished SKU, material product, yield per kg, optional yield by size, optional color/family overrides, and secondary materials when needed.
* **Imported product compositions:** Tiny/Olist structures are stored as immutable-ish sync facts: finished SKU/Tiny ID/unit, component SKU/Tiny ID/name, quantity per finished unit, and component unit (`KG`, `UN`, etc.). These records support Product Details composition display and drive `tiny_structure` consumption references.
* **Cut family mapping:** rules that say which colors/sizes/products can be planned together for cutting.
* **Open production/cutting data:** quantities already in production, expected finish date, linked finished SKU, and linked raw material when available.
* **Business rules:** target coverage days, minimum stock, safety stock, purchase lead time, production lead time, and discontinued/ignored SKU rules.
**Current UI/data-flow behavior:**
* `Cadastros` has an `Importar composições` JSON upload action for exports like `composicoes_produtos_YYYY-MM-DD.json`. The same import is available to scripts through `POST /api/production-orders/tiny-compositions/import` using `x-api-key`.
* Composition imports are idempotent. Re-importing the same Tiny/Olist export updates composition rows/components and refreshes derived `tiny_structure` consumption references instead of duplicating them.
* `Product Details > Composição` shows the synced component list for the current product when a composition exists.
* `Suprimentos > Necessidade de Compra` derives material pressure from demand and stock, but rows without a consumption reference are marked as missing reference instead of pretending to calculate kg.
* Imported `tiny_structure` consumption references now allow `Necessidade de Compra` to calculate mixed-unit material demand from real BOM lines, e.g. malha/ribana in `KG` and etiqueta/ilhós/atacador in `UN`.
* Missing-reference rows link into `Cadastros > Referência de Consumo` with SKU context. If the SKU does not exist in the local catalog yet, `Cadastros` opens the product form first.
* `Cadastros > Referência de Consumo` labels reference sources as `Manual`, `Tiny OP`, or `Tiny Estrutura`.
* `Plano de Corte` SKU edit actions deep-link into the cutting configuration drawer for that SKU.
* Product tables and group detail tables are tuned for high-volume values, including large `Total Vendido` quantities above 100k.
**Current composition import status:**
* Local import tested with `/home/farelos/Downloads/composicoes_produtos_2026-07-31.json`.
* Imported successfully: `437` compositions, `1,552` component rows, and `1,316` `tiny_structure` consumption references.
* Known data issues from that export: `4` sellable finished products are missing SKU; `54` structures are raw material/service structures and are stored as compositions but intentionally not promoted into sellable demand-planning references; `236` component/reference lines were skipped because their parent structure was non-sellable or the finished SKU was missing.
* Sellable-family detection currently includes `DTF`, `CAMISETA`, `CAMISA`, `MOLETOM`, `CANGURU`, `REGATA`, and `POLO`. Raw/input/service structures are excluded from demand-reference promotion when descriptions contain terms like `MALHA`, `RIBANA`, `FIO`, `TECIDO`, `SERVIÇO`, `TINTURARIA`, `TECELAGEM`, or `FRETE`.
**Recommended next steps now that composition data exists:**
* Make `Necessidade de Compra` clearer and more auditable: show `demanda do SKU -> consumo por unidade -> material necessário -> estoque atual -> comprar`.
* Add a data-health/review screen for missing finished SKU, missing component stock link, suspicious quantities, products with sales but no composition, and non-sellable structures.
* Connect material stock more explicitly to imported component SKUs/Tiny IDs so material purchase needs subtract the right current stock and pending receipts.
* Upgrade `Planejamento de Corte` to show material blockers by family/color/size, using imported composition references and current stock.
* Add production-plan status flow only after material needs and composition health are reliable.
## 7. 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/`.
@@ -168,6 +188,17 @@ docker compose up -d --build
```
The local Docker services use `restart: unless-stopped`, so containers should come back after laptop restart if Docker starts.
**Importing Tiny/Olist Product Compositions:**
* From the app: open `Cadastros` and use `Importar composições` with the exported JSON file.
* From a script/API client:
```bash
curl -X POST http://localhost:3004/api/production-orders/tiny-compositions/import \
-H 'Content-Type: application/json' \
-H 'x-api-key: nexstar_secret_key_123' \
--data-binary @/path/to/composicoes_produtos_YYYY-MM-DD.json
```
* Expected response includes `imported`, `failed`, `componentCount`, `referenceCount`, `skippedReferenceCount`, and `issues`.
## 9. 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.
@@ -203,6 +234,9 @@ Files most relevant to RFV:
* `src/displayFormatters.ts` - shared seller/fantasy/client metadata display cleanup.
Recent commits related to supply/cutting/data-flow and chart readability:
* `37d4a77 Import Tiny product compositions` - adds the Tiny/Olist composition JSON import flow, derived `tiny_structure` consumption references, app upload button, API-key import endpoint, and import tests.
* `800eb97 Derive consumption references from Tiny OP sync` - Tiny OP composition sync now creates catalog products/materials and reusable consumption references from OP component lines.
* `c07938c Add Tiny production order detail sync` - stores OP detail rows, components, steps, and observations from Tiny/Olist sync payloads.
* `060b4da Connect supplies to project demand` - Suprimentos purchase needs now derive from project/order demand and stock, while missing consumption references are surfaced explicitly.
* `bc05fb4 Add SKU edit actions` - product/cutting/replenishment/group tables gained compact SKU view/edit actions.
* `63efb47 Route SKU actions to focused editors` - cut-plan edit opens SKU cutting configuration; missing-reference supply rows open the consumption reference flow.
@@ -218,3 +252,6 @@ Files most relevant to supply/cutting/data-flow:
* `src/analytics/cutting.ts` - cut-family parsing, cut needs, stock coverage, and product override logic.
* `src/pages/Products.tsx` and `src/pages/ProductGroupDetails.tsx` - product/group velocity, stock coverage, and high-volume display.
* `src/chartUtils.ts` - shared date bucketing, moving average, unknown-label cleanup, and chart date helpers.
* `backend/services/productionOrderService.js` - Tiny OP detail sync, Tiny/Olist structure import, composition storage, sellable structure filtering, and derived consumption-reference creation.
* `backend/routes/productionOrderRoutes.js` and `backend/routes/catalogRoutes.js` - API-key script import and authenticated app import endpoints for composition JSON.
* `backend/test/productCompositionService.test.js` - coverage for Tiny/Olist structure sync and bulk composition import behavior.

View File

@@ -112,8 +112,17 @@ N8N_WHATSAPP_TRIGGER_URL
ADMIN_EMAIL
ADMIN_PASSWORD
JWT_SECRET
TURNSTILE_SITE_KEY
TURNSTILE_SECRET
```
`TURNSTILE_SECRET` enables backend CAPTCHA enforcement on `/api/login`. Set
`TURNSTILE_SITE_KEY` with Cloudflare's public site key so the login page can load
the verification widget at runtime.
The backend also accepts `VITE_TURNSTILE_SITE_KEY` as a site-key alias and
`TURNSTILE_SECRET_KEY` as a secret alias for older deployments.
## Validation
```bash

View File

@@ -1,5 +1,34 @@
require('dotenv').config();
const TURNSTILE_KEY_MATCH = /[0-9]x[0-9A-Za-z_-]{20,}/;
const normalizeTurnstileValue = (value) => {
if (typeof value !== 'string') return '';
const trimmedValue = value.trim();
const keyMatch = trimmedValue.match(TURNSTILE_KEY_MATCH);
if (keyMatch) return keyMatch[0];
return trimmedValue.replace(/^[\s"'`{[]+|[\s"'`}\]]+$/g, '');
};
const firstEnvValue = (...values) => values
.map(normalizeTurnstileValue)
.find(Boolean) || '';
const TURNSTILE_SITE_KEY = firstEnvValue(
process.env.TURNSTILE_SITE_KEY,
process.env.TURNSTILE_SITEKEY,
process.env.VITE_TURNSTILE_SITE_KEY,
process.env.CLOUDFLARE_TURNSTILE_SITE_KEY
);
const TURNSTILE_SECRET = firstEnvValue(
process.env.TURNSTILE_SECRET,
process.env.TURNSTILE_SECRET_KEY,
process.env.CLOUDFLARE_TURNSTILE_SECRET
);
module.exports = {
PORT: process.env.PORT || 3004,
API_KEY: process.env.API_KEY || 'nexstar_secret_key_123',
@@ -7,5 +36,7 @@ module.exports = {
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'
N8N_WHATSAPP_TRIGGER_URL: process.env.N8N_WHATSAPP_TRIGGER_URL || 'http://localhost:5678/webhook/whatsapp',
TURNSTILE_SITE_KEY,
TURNSTILE_SECRET
};

View File

@@ -5,6 +5,29 @@ const pool = new Pool({
connectionString: DATABASE_URL
});
const defaultCatalogCategories = [
['Camiseta regular', 'Bases lisas e camisetas adultas.'],
['Camiseta infantil', 'Produtos infantis por cor e tamanho.'],
['Moletom', 'Moletons, cangurus e produtos de frio.'],
['Oversized', 'Modelagens oversized e variações relacionadas.'],
['Acessórios', 'Bonés, itens complementares e produtos não têxteis.'],
['DTF', 'Insumos e serviços relacionados a impressão DTF.'],
['Malha', 'Tecidos e malhas usados como matéria-prima.'],
['Aviamentos', 'Ribanas, linhas, ilhós e componentes de costura.'],
['Embalagens', 'Sacos, etiquetas, tags e materiais de expedição.'],
['Insumos gerais', 'Materiais de apoio sem família operacional específica.']
];
const seedDefaultCatalogCategories = async () => {
for (const [name, description] of defaultCatalogCategories) {
await pool.query(`
INSERT INTO catalog_categories (name, description, updated_at)
VALUES ($1, $2, CURRENT_TIMESTAMP)
ON CONFLICT (name) DO NOTHING;
`, [name, description]);
}
};
const initDB = async () => {
try {
await pool.query(`SET TIME ZONE 'America/Sao_Paulo';`);
@@ -105,6 +128,13 @@ const initDB = async () => {
quantity NUMERIC(14, 4) DEFAULT 0,
unit VARCHAR(20) DEFAULT 'UN',
integration_status VARCHAR(100),
notes TEXT,
supplier TEXT,
lot_code VARCHAR(120),
roll_quantity NUMERIC(14, 4),
fabric_kg NUMERIC(14, 4),
rib_kg NUMERIC(14, 4),
yield_pieces_per_kg NUMERIC(14, 4),
tiny_payload JSONB,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
@@ -122,6 +152,70 @@ const initDB = async () => {
);
`);
await pool.query(`
CREATE TABLE IF NOT EXISTS production_order_components (
id SERIAL PRIMARY KEY,
production_order_id INTEGER NOT NULL REFERENCES production_orders(id) ON DELETE CASCADE,
component_tiny_id VARCHAR(100),
component_sku VARCHAR(255),
component_name TEXT NOT NULL,
quantity_per_unit NUMERIC(14, 4) DEFAULT 0,
total_quantity NUMERIC(14, 4) DEFAULT 0,
unit VARCHAR(30),
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
`);
await pool.query(`
CREATE TABLE IF NOT EXISTS production_order_steps (
id SERIAL PRIMARY KEY,
production_order_id INTEGER NOT NULL REFERENCES production_orders(id) ON DELETE CASCADE,
step_number INTEGER,
name VARCHAR(160) NOT NULL,
start_date DATE,
end_date DATE,
status VARCHAR(80),
color VARCHAR(40),
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
`);
await pool.query(`
CREATE TABLE IF NOT EXISTS product_compositions (
id SERIAL PRIMARY KEY,
source VARCHAR(60) NOT NULL,
external_source_id VARCHAR(120) NOT NULL,
finished_product_identity VARCHAR(255) NOT NULL,
finished_product_sku VARCHAR(255),
finished_product_description TEXT NOT NULL,
finished_product_unit VARCHAR(30) NOT NULL DEFAULT 'UN',
finished_tiny_product_id VARCHAR(100),
source_metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
last_synced_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
UNIQUE (source, finished_product_identity)
);
`);
await pool.query(`
CREATE TABLE IF NOT EXISTS product_composition_components (
id SERIAL PRIMARY KEY,
product_composition_id INTEGER NOT NULL REFERENCES product_compositions(id) ON DELETE CASCADE,
component_identity VARCHAR(255) NOT NULL,
component_tiny_id VARCHAR(100),
component_sku VARCHAR(255),
component_name TEXT NOT NULL,
quantity_per_unit NUMERIC(14, 4) NOT NULL DEFAULT 0,
unit VARCHAR(30),
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
UNIQUE (product_composition_id, component_identity)
);
`);
await pool.query(`
CREATE TABLE IF NOT EXISTS cutting_family_rules (
family_key VARCHAR(20) PRIMARY KEY,
@@ -185,6 +279,10 @@ const initDB = async () => {
efficiency_percent NUMERIC(7, 3),
rib_g_per_piece NUMERIC(14, 4),
material_cost_per_kg NUMERIC(14, 4),
consumption_quantity NUMERIC(14, 4),
consumption_unit VARCHAR(30),
source VARCHAR(60) DEFAULT 'manual',
last_production_order_id INTEGER REFERENCES production_orders(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
@@ -254,6 +352,33 @@ const initDB = async () => {
await pool.query(`
ALTER TABLE production_orders
ADD COLUMN IF NOT EXISTS notes TEXT,
ADD COLUMN IF NOT EXISTS supplier TEXT,
ADD COLUMN IF NOT EXISTS lot_code VARCHAR(120),
ADD COLUMN IF NOT EXISTS roll_quantity NUMERIC(14, 4),
ADD COLUMN IF NOT EXISTS fabric_kg NUMERIC(14, 4),
ADD COLUMN IF NOT EXISTS rib_kg NUMERIC(14, 4),
ADD COLUMN IF NOT EXISTS yield_pieces_per_kg NUMERIC(14, 4);
`).catch(() => {});
await pool.query(`
ALTER TABLE production_orders
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;
`).catch(() => {});
await pool.query(`
ALTER TABLE production_order_components
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;
`).catch(() => {});
await pool.query(`
ALTER TABLE production_order_steps
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',
@@ -294,6 +419,14 @@ const initDB = async () => {
ALTER COLUMN updated_at SET DEFAULT CURRENT_TIMESTAMP;
`).catch(() => {});
await pool.query(`
ALTER TABLE consumption_references
ADD COLUMN IF NOT EXISTS consumption_quantity NUMERIC(14, 4),
ADD COLUMN IF NOT EXISTS consumption_unit VARCHAR(30),
ADD COLUMN IF NOT EXISTS source VARCHAR(60) DEFAULT 'manual',
ADD COLUMN IF NOT EXISTS last_production_order_id INTEGER REFERENCES production_orders(id) ON DELETE SET NULL;
`).catch(() => {});
await pool.query(`
ALTER TABLE consumption_references
ALTER COLUMN created_at TYPE TIMESTAMPTZ USING created_at AT TIME ZONE 'America/Sao_Paulo',
@@ -302,6 +435,8 @@ const initDB = async () => {
ALTER COLUMN updated_at SET DEFAULT CURRENT_TIMESTAMP;
`).catch(() => {});
await seedDefaultCatalogCategories();
await pool.query(`
ALTER TABLE supply_receipts
ALTER COLUMN created_at TYPE TIMESTAMPTZ USING created_at AT TIME ZONE 'America/Sao_Paulo',
@@ -400,11 +535,27 @@ const initDB = async () => {
await pool.query(`CREATE INDEX IF NOT EXISTS idx_production_orders_issue_date ON production_orders (issue_date DESC);`);
await pool.query(`CREATE INDEX IF NOT EXISTS idx_production_orders_expected_date ON production_orders (expected_date DESC);`);
await pool.query(`CREATE INDEX IF NOT EXISTS idx_production_order_markers_order_id ON production_order_markers (production_order_id);`);
await pool.query(`CREATE INDEX IF NOT EXISTS idx_production_order_components_order_id ON production_order_components (production_order_id);`);
await pool.query(`CREATE INDEX IF NOT EXISTS idx_production_order_components_sku ON production_order_components (component_sku);`);
await pool.query(`CREATE INDEX IF NOT EXISTS idx_production_order_steps_order_id ON production_order_steps (production_order_id);`);
await pool.query(`CREATE INDEX IF NOT EXISTS idx_product_compositions_finished_tiny_id ON product_compositions (finished_tiny_product_id);`);
await pool.query(`CREATE INDEX IF NOT EXISTS idx_product_composition_components_tiny_id ON product_composition_components (component_tiny_id);`);
await pool.query(`CREATE INDEX IF NOT EXISTS idx_cutting_product_overrides_family_key ON cutting_product_overrides (family_key);`);
await pool.query(`CREATE INDEX IF NOT EXISTS idx_catalog_products_type ON catalog_products (type);`);
await pool.query(`CREATE INDEX IF NOT EXISTS idx_catalog_products_category_id ON catalog_products (category_id);`);
await pool.query(`CREATE INDEX IF NOT EXISTS idx_consumption_references_product_id ON consumption_references (product_id);`);
await pool.query(`CREATE INDEX IF NOT EXISTS idx_consumption_references_material_product_id ON consumption_references (material_product_id);`);
await pool.query(`
CREATE UNIQUE INDEX IF NOT EXISTS unique_consumption_reference_source
ON consumption_references (
product_id,
COALESCE(material_product_id, 0),
COALESCE(color, ''),
COALESCE(source, 'manual')
);
`).catch(err => {
console.error('Notice: Could not create unique consumption reference source index:', err.message);
});
await pool.query(`CREATE INDEX IF NOT EXISTS idx_supply_receipts_status ON supply_receipts (status);`);
await pool.query(`CREATE INDEX IF NOT EXISTS idx_supply_receipts_created_at ON supply_receipts (created_at DESC);`);
await pool.query(`CREATE INDEX IF NOT EXISTS idx_supply_stock_lots_status ON supply_stock_lots (status);`);

View File

@@ -6,10 +6,13 @@ const {
getClientFilterOptions,
getClientPurchasePatternAnalytics,
getDashboardAnalytics,
getSellerAnalytics,
getSellerDetailsAnalytics,
getProductAnalytics,
getProductDetailsAnalytics,
getRfmAnalytics
} = require('../services/analyticsService');
const { getProductComposition, listProductCompositions } = require('../services/productionOrderService');
const router = express.Router();
@@ -34,6 +37,29 @@ router.get('/analytics/dashboard', verifyToken, async (req, res) => {
}
});
router.get('/analytics/sellers', verifyToken, async (req, res) => {
try {
res.json(await getSellerAnalytics(getRange(req.query)));
} catch (error) {
console.error('Error fetching seller analytics:', error);
res.status(500).json({ error: 'Internal Server Error' });
}
});
router.get('/analytics/sellers/:sellerId/details', verifyToken, async (req, res) => {
try {
const details = await getSellerDetailsAnalytics(req.params.sellerId, getRange(req.query));
if (!details) {
res.status(404).json({ error: 'Seller not found' });
return;
}
res.json(details);
} catch (error) {
console.error('Error fetching seller details 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)));
@@ -58,6 +84,25 @@ router.get('/analytics/products/:productId/details', verifyToken, async (req, re
}
});
router.get('/analytics/products/:productId/composition', verifyToken, async (req, res) => {
try {
const composition = await getProductComposition(req.params.productId);
res.json({ composition });
} catch (error) {
console.error('Error fetching product composition:', error);
res.status(500).json({ error: 'Internal Server Error' });
}
});
router.get('/analytics/product-compositions', verifyToken, async (req, res) => {
try {
res.json({ compositions: await listProductCompositions() });
} catch (error) {
console.error('Error exporting product compositions:', error);
res.status(500).json({ error: 'Internal Server Error' });
}
});
router.get('/analytics/clients', verifyToken, async (req, res) => {
try {
res.json(await getClientAnalytics(getClientAnalyticsFilters(req.query)));

View File

@@ -1,12 +1,59 @@
const express = require('express');
const { login } = require('../auth');
const { TURNSTILE_SECRET, TURNSTILE_SITE_KEY } = require('../config');
const router = express.Router();
const TURNSTILE_VERIFY_URL = 'https://challenges.cloudflare.com/turnstile/v0/siteverify';
const TURNSTILE_SITE_KEY_PATTERN = /^[0-9]x[0-9A-Za-z_-]{20,}$/;
const hasValidTurnstileSiteKey = TURNSTILE_SITE_KEY_PATTERN.test(TURNSTILE_SITE_KEY);
router.post('/login', async (req, res, next) => {
const { email, password } = req.body;
const verifyCaptcha = async (captchaToken, remoteIp) => {
if (!TURNSTILE_SECRET) return true;
if (!captchaToken || typeof captchaToken !== 'string') return false;
try {
const formData = new URLSearchParams({
secret: TURNSTILE_SECRET,
response: captchaToken
});
if (remoteIp) {
formData.set('remoteip', remoteIp);
}
const response = await fetch(TURNSTILE_VERIFY_URL, {
method: 'POST',
body: formData
});
if (!response.ok) return false;
const result = await response.json();
return result.success === true;
} catch (error) {
console.error('Captcha verification failed', error);
return false;
}
};
router.get('/login/config', (req, res) => {
res.json({
captchaRequired: Boolean(TURNSTILE_SECRET),
turnstileSiteKey: hasValidTurnstileSiteKey ? TURNSTILE_SITE_KEY : '',
captchaConfigured: Boolean(TURNSTILE_SECRET && hasValidTurnstileSiteKey)
});
});
router.post('/login', async (req, res, next) => {
const { email, password, captchaToken } = req.body;
try {
const captchaValid = await verifyCaptcha(captchaToken, req.ip);
if (!captchaValid) {
res.status(403).json({ error: 'Captcha verification failed' });
return;
}
const authResult = await login(email, password);
if (!authResult) {

View File

@@ -1,14 +1,24 @@
const express = require('express');
const { verifyToken } = require('../auth');
const { authenticateAPIKey, verifyToken } = require('../auth');
const {
getCampaignPreview,
getCampaignQueueSummary,
getTopClientsForCampaign,
processPendingStockCampaigns,
retryCampaignItems
} = require('../services/campaignService');
const router = express.Router();
const verifyCampaignExportAccess = (req, res, next) => {
if (req.headers['x-api-key']) {
authenticateAPIKey(req, res, next);
return;
}
verifyToken(req, res, next);
};
router.get('/campaigns', verifyToken, async (req, res) => {
try {
res.json(await getCampaignQueueSummary());
@@ -27,6 +37,15 @@ router.get('/campaigns/preview', verifyToken, async (req, res) => {
}
});
router.get('/campaigns/top-clients', verifyCampaignExportAccess, async (req, res) => {
try {
res.json(await getTopClientsForCampaign(req.query || {}));
} catch (error) {
console.error('Error fetching top campaign clients:', error);
res.status(500).json({ error: 'Internal Server Error' });
}
});
router.post('/campaigns/process', verifyToken, async (req, res) => {
try {
res.json(await processPendingStockCampaigns());

View File

@@ -12,6 +12,7 @@ const {
listConsumptionReferences,
listProducts
} = require('../services/catalogService');
const { importTinyProductCompositionExport } = require('../services/productionOrderService');
const router = express.Router();
@@ -89,6 +90,14 @@ router.post('/catalog/consumption-references', verifyToken, async (req, res, nex
}
});
router.post('/catalog/product-compositions/import', verifyToken, async (req, res, next) => {
try {
res.status(201).json(await importTinyProductCompositionExport(req.body || {}));
} catch (error) {
next(error);
}
});
router.delete('/catalog/consumption-references/:id', verifyToken, async (req, res, next) => {
try {
await deleteConsumptionReference(req.params.id);

View File

@@ -0,0 +1,15 @@
const express = require('express');
const { verifySuperAdmin } = require('../auth');
const { buildDatabaseDiagnostic } = require('../services/databaseDiagnosticService');
const router = express.Router();
router.get('/admin/database-diagnostic', verifySuperAdmin, async (req, res, next) => {
try {
res.json(await buildDatabaseDiagnostic(req.user));
} catch (error) {
next(error);
}
});
module.exports = router;

View File

@@ -1,6 +1,6 @@
const express = require('express');
const { verifyToken } = require('../auth');
const { listProductionOrders } = require('../services/productionOrderService');
const { authenticateAPIKey, verifyToken } = require('../auth');
const { createProductionOrders, importTinyProductCompositionExport, listProductionOrders, updateProductionOrderStatus, upsertTinyProductionOrderDetail } = require('../services/productionOrderService');
const router = express.Router();
@@ -13,4 +13,41 @@ router.get('/production-orders', verifyToken, async (req, res) => {
}
});
router.post('/production-orders', verifyToken, async (req, res) => {
try {
const orders = Array.isArray(req.body?.orders) ? req.body.orders : [];
res.status(201).json(await createProductionOrders(orders));
} catch (error) {
console.error('Error creating production orders:', error);
res.status(error.statusCode || 500).json({ error: error.message || 'Internal Server Error' });
}
});
router.post('/production-orders/tiny-sync', authenticateAPIKey, async (req, res) => {
try {
res.status(201).json(await upsertTinyProductionOrderDetail(req.body || {}));
} catch (error) {
console.error('Error syncing Tiny production order:', error);
res.status(error.statusCode || 500).json({ error: error.message || 'Internal Server Error' });
}
});
router.post('/production-orders/tiny-compositions/import', authenticateAPIKey, async (req, res) => {
try {
res.status(201).json(await importTinyProductCompositionExport(req.body || {}));
} catch (error) {
console.error('Error importing Tiny product compositions:', error);
res.status(error.statusCode || 500).json({ error: error.message || 'Internal Server Error' });
}
});
router.patch('/production-orders/:id/status', verifyToken, async (req, res) => {
try {
res.json(await updateProductionOrderStatus(req.params.id, req.body?.status));
} catch (error) {
console.error('Error updating production order status:', error);
res.status(error.statusCode || 500).json({ error: error.message || 'Internal Server Error' });
}
});
module.exports = router;

View File

@@ -12,12 +12,13 @@ const productionOrderRoutes = require('./routes/productionOrderRoutes');
const cuttingSettingsRoutes = require('./routes/cuttingSettingsRoutes');
const catalogRoutes = require('./routes/catalogRoutes');
const supplyRoutes = require('./routes/supplyRoutes');
const databaseDiagnosticRoutes = require('./routes/databaseDiagnosticRoutes');
const createApp = () => {
const app = express();
app.use(cors());
app.use(bodyParser.json());
app.use(bodyParser.json({ limit: '10mb' }));
app.use('/api', authRoutes);
app.use('/api', dataRoutes);
@@ -27,6 +28,7 @@ const createApp = () => {
app.use('/api', cuttingSettingsRoutes);
app.use('/api', catalogRoutes);
app.use('/api', supplyRoutes);
app.use('/api', databaseDiagnosticRoutes);
app.use('/api', analyticsRoutes);
app.use('/api', userRoutes);
app.use('/api/internal', internalRoutes);

View File

@@ -694,6 +694,119 @@ const getDashboardAnalytics = async (range = {}) => {
};
};
const getSellerAnalytics = async (range = {}) => {
const { params, whereClause } = buildDateFilter(range);
const result = await pool.query(`
WITH seller_orders AS (
SELECT
COALESCE(${SELLER_ID_SQL}, 'name:' || ${SELLER_NAME_SQL}) as seller_id,
COALESCE(${SELLER_NAME_SQL}, ${SELLER_ID_SQL}, 'Sem vendedor') as seller_name,
quantidade,
valor_unitario,
pedido_id,
data_pedido,
valor_pedido,
data_pedido_date,
COALESCE(NULLIF(cliente_fone, ''), 'name:' || COALESCE(${NORMALIZED_CUSTOMER_NAME_SQL}, 'desconhecido')) as customer_key
FROM orders
${whereClause}
AND (${SELLER_ID_SQL} IS NOT NULL OR ${SELLER_NAME_SQL} IS NOT NULL)
)
SELECT
seller_id as id,
MAX(seller_name) as name,
COALESCE(SUM(quantidade * valor_unitario), 0) as revenue,
COALESCE(SUM(quantidade), 0) as quantity_sold,
COUNT(DISTINCT COALESCE(NULLIF(pedido_id, ''), data_pedido || '_' || valor_pedido::text))::int as order_count,
COUNT(DISTINCT customer_key)::int as customer_count,
MAX(data_pedido_date) as last_order_date
FROM seller_orders
GROUP BY seller_id
ORDER BY revenue DESC, order_count DESC, name ASC;
`, params);
return result.rows.map(row => ({
id: row.id,
name: row.name,
revenue: toNumber(row.revenue),
quantitySold: toNumber(row.quantity_sold),
orderCount: toNumber(row.order_count),
customerCount: toNumber(row.customer_count),
lastOrderDate: row.last_order_date,
averageTicket: toNumber(row.order_count) ? toNumber(row.revenue) / toNumber(row.order_count) : 0
}));
};
const getSellerDetailsAnalytics = async (sellerId, range = {}) => {
const seller = normalizeSellerFilter(sellerId);
if (!seller) return null;
const sellerRange = { ...range, seller: sellerId };
const { params, whereClause } = buildDateFilter(sellerRange);
const [summaryResult, trendResult, marketplaceResult, products, clients] = await Promise.all([
pool.query(`
SELECT
COALESCE(MAX(${SELLER_NAME_SQL}), MAX(${SELLER_ID_SQL}), 'Sem vendedor') as name,
COALESCE(SUM(quantidade * valor_unitario), 0) as revenue,
COALESCE(SUM(quantidade), 0) as quantity_sold,
COUNT(DISTINCT COALESCE(NULLIF(pedido_id, ''), data_pedido || '_' || valor_pedido::text))::int as order_count,
MAX(data_pedido_date) as last_order_date
FROM orders
${whereClause};
`, params),
pool.query(`
SELECT
data_pedido_date::text as date,
COALESCE(SUM(quantidade * valor_unitario), 0) as revenue,
COALESCE(SUM(quantidade), 0) as quantity_sold,
COUNT(DISTINCT COALESCE(NULLIF(pedido_id, ''), data_pedido || '_' || valor_pedido::text))::int as order_count
FROM orders
${whereClause}
GROUP BY data_pedido_date
ORDER BY data_pedido_date ASC;
`, params),
pool.query(`
SELECT
COALESCE(NULLIF(TRIM(marketplace), ''), 'Sem marketplace') as name,
COALESCE(SUM(quantidade * valor_unitario), 0) as revenue,
COUNT(DISTINCT COALESCE(NULLIF(pedido_id, ''), data_pedido || '_' || valor_pedido::text))::int as order_count
FROM orders
${whereClause}
GROUP BY name
ORDER BY revenue DESC
LIMIT 6;
`, params),
getProductAnalytics(sellerRange),
getClientAnalytics(sellerRange)
]);
const summary = summaryResult.rows[0];
if (!summary || (!toNumber(summary.revenue) && !toNumber(summary.order_count))) return null;
const orderCount = toNumber(summary.order_count);
return {
seller: {
id: String(sellerId),
name: summary.name,
revenue: toNumber(summary.revenue),
quantitySold: toNumber(summary.quantity_sold),
orderCount,
customerCount: clients.length,
lastOrderDate: summary.last_order_date,
averageTicket: orderCount ? toNumber(summary.revenue) / orderCount : 0
},
chartData: trendResult.rows.map(row => ({
date: row.date,
revenue: toNumber(row.revenue),
quantitySold: toNumber(row.quantity_sold),
orderCount: toNumber(row.order_count)
})),
topProducts: products.slice(0, 8),
topClients: clients.slice(0, 8),
marketplaces: marketplaceResult.rows.map(row => ({ name: row.name, revenue: toNumber(row.revenue), orderCount: toNumber(row.order_count) }))
};
};
const getProductAnalytics = async (range = {}) => {
const { params, whereClause } = buildDateFilter(range);
const result = await pool.query(`
@@ -1533,6 +1646,8 @@ module.exports = {
getRfmSegment,
getClientAnalytics,
getDashboardAnalytics,
getSellerAnalytics,
getSellerDetailsAnalytics,
getProductDetailsAnalytics,
getProductAnalytics,
normalizeDateParam,

View File

@@ -6,6 +6,26 @@ const applyProductDisplayAlias = (name) => {
.replace(/^BASE LISA MOLETOM CANGURU\b/i, 'MOLETOM CANGURU PREMIUM');
};
const normalizeCampaignProductName = (name) => String(name || '')
.normalize('NFD')
.replace(/\p{Diacritic}/gu, '')
.replace(/\s+/g, ' ')
.trim()
.toUpperCase();
const isCampaignEligibleProductName = (name) => {
const normalizedName = normalizeCampaignProductName(name);
if (!normalizedName) return false;
if (/^(?:\d+(?:\.\d+)?\s+)?(?:MALHA|RIBANA)\b/.test(normalizedName)) return false;
if (/\b(?:TINTA|FILME|POLIAMIDA|PO PARA DTF|ROLO DTF|FLUIDO|PRIMER|CABECA|SENSOR|FILTRO|BOMBA)\b.*\bDTF\b/.test(normalizedName)) return false;
if (/\b(?:MALHA|RIBANA|ATACADOR|ILHOS|ETIQUETA|TAG|FITA|LINHA PARA COSTURA|FIO|TECIDO|RETALHO|RESIDUO)\b/.test(normalizedName)) return false;
if (/\b(?:SALDO ESTOQUE|FRETE|SERVICO|TRANSPORTE|TECELAGEM|TINTURARIA)\b/.test(normalizedName)) return false;
if (/\b(?:PRENSA|MAQUINA|OVERLOCK|OVERLOK|GALONEIRA|PRATELEIRA|CABO FLAT|WIPPER|PRIMER|FLUIDO|SENSOR|FILTRO)\b/.test(normalizedName)) return false;
return /\b(?:DTF|CAMISETA|CAMISA|MOLETOM|CANGURU|REGATA|OVERSIZE|OVER SIZE)\b/.test(normalizedName);
};
const formatProductNameForDisplay = (name) => {
return applyProductDisplayAlias(name)
.toLocaleLowerCase('pt-BR')
@@ -103,5 +123,6 @@ module.exports = {
formatProductList,
groupCampaignRows,
groupCampaignRowsByBaseProduct,
isCampaignEligibleProductName,
mapCampaignProducts
};

View File

@@ -5,14 +5,118 @@ const {
formatProductList,
groupCampaignRows,
groupCampaignRowsByBaseProduct,
isCampaignEligibleProductName,
mapCampaignProducts
} = require('./campaignFormatter');
const TOP_BUYERS_LIMIT = 100;
const TOP_CLIENTS_DEFAULT_DAYS = 30;
const TOP_CLIENTS_DEFAULT_LIMIT = 1000;
const TOP_CLIENTS_MAX_LIMIT = 5000;
const MAX_CAMPAIGN_ATTEMPTS = 3;
const CAMPAIGN_DELTA_THRESHOLD = 100;
const SAO_PAULO_TIME_ZONE = 'America/Sao_Paulo';
const NORMALIZED_CUSTOMER_NAME_SQL = "NULLIF(LOWER(TRIM(regexp_replace(COALESCE(cliente_nome, ''), '\\s+', ' ', 'g'))), '')";
const NORMALIZED_CUSTOMER_PHONE_SQL = "NULLIF(regexp_replace(COALESCE(cliente_fone, ''), '\\D', '', 'g'), '')";
const WHATSAPP_CUSTOMER_PHONE_SQL = `
CASE
WHEN ${NORMALIZED_CUSTOMER_PHONE_SQL} LIKE '55%' THEN ${NORMALIZED_CUSTOMER_PHONE_SQL}
WHEN length(${NORMALIZED_CUSTOMER_PHONE_SQL}) IN (10, 11) THEN '55' || ${NORMALIZED_CUSTOMER_PHONE_SQL}
ELSE ${NORMALIZED_CUSTOMER_PHONE_SQL}
END
`;
const CUSTOMER_IDENTITY_CTE = `
WITH customer_phone_by_name AS (
SELECT
${NORMALIZED_CUSTOMER_NAME_SQL} as normalized_customer_name,
(ARRAY_AGG(NULLIF(cliente_fone, '') ORDER BY data_pedido_date DESC NULLS LAST, id DESC)
)[1] as canonical_phone
FROM orders
WHERE NULLIF(cliente_fone, '') IS NOT NULL
AND ${NORMALIZED_CUSTOMER_NAME_SQL} IS NOT NULL
GROUP BY normalized_customer_name
),
identity_orders AS (
SELECT
orders.*,
${NORMALIZED_CUSTOMER_NAME_SQL} as normalized_customer_name,
COALESCE(
NULLIF(orders.cliente_fone, ''),
customer_phone_by_name.canonical_phone,
'name:' || COALESCE(NULLIF(orders.cliente_nome, ''), 'Cliente Desconhecido')
) as customer_key
FROM orders
LEFT JOIN customer_phone_by_name
ON customer_phone_by_name.normalized_customer_name = ${NORMALIZED_CUSTOMER_NAME_SQL}
)
`;
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 parsePositiveInteger = (value, defaultValue, maxValue) => {
const parsed = Number.parseInt(value, 10);
if (!Number.isFinite(parsed) || parsed < 1) return defaultValue;
return Math.min(parsed, maxValue);
};
const getDateStringInTimeZone = (date = new Date(), timeZone = SAO_PAULO_TIME_ZONE) => {
const parts = new Intl.DateTimeFormat('en-US', {
timeZone,
year: 'numeric',
month: '2-digit',
day: '2-digit'
}).formatToParts(date);
const partMap = Object.fromEntries(parts.map(part => [part.type, part.value]));
return `${partMap.year}-${partMap.month}-${partMap.day}`;
};
const subtractDaysFromDateString = (dateString, daysToSubtract) => {
const [year, month, day] = dateString.split('-').map(Number);
const date = new Date(Date.UTC(year, month - 1, day));
date.setUTCDate(date.getUTCDate() - daysToSubtract);
return date.toISOString().slice(0, 10);
};
const getTopClientsDateRange = ({ days = TOP_CLIENTS_DEFAULT_DAYS, start, end } = {}) => {
const normalizedDays = parsePositiveInteger(days, TOP_CLIENTS_DEFAULT_DAYS, 3650);
const normalizedEnd = normalizeDateParam(end) || getDateStringInTimeZone();
const normalizedStart = normalizeDateParam(start) || subtractDaysFromDateString(normalizedEnd, normalizedDays - 1);
return {
days: normalizedDays,
start: normalizedStart,
end: normalizedEnd
};
};
const enqueueStockCampaignItem = async (client, item) => {
if (!isCampaignEligibleProductName(item.baseProductName || item.nome)) {
return false;
}
const query = `
INSERT INTO stock_campaign_queue (
base_product_name, produto_id, nome, saldo, delta_estoque
@@ -26,6 +130,8 @@ const enqueueStockCampaignItem = async (client, item) => {
item.saldo,
item.deltaEstoque
]);
return true;
};
const getTopBuyersAllTime = async () => {
@@ -46,6 +152,63 @@ const getTopBuyersAllTime = async () => {
return result.rows;
};
const getTopClientsForCampaign = async ({ days, limit, start, end } = {}) => {
const range = getTopClientsDateRange({ days, start, end });
const normalizedLimit = parsePositiveInteger(limit, TOP_CLIENTS_DEFAULT_LIMIT, TOP_CLIENTS_MAX_LIMIT);
const result = await pool.query(`
WITH campaign_orders AS (
SELECT
orders.*,
${WHATSAPP_CUSTOMER_PHONE_SQL} as whatsapp_phone
FROM orders
)
SELECT
(
ARRAY_AGG(COALESCE(NULLIF(cliente_nome, ''), 'Cliente Desconhecido')
ORDER BY data_pedido_date DESC NULLS LAST, id DESC)
)[1] as nome,
(
ARRAY_AGG(whatsapp_phone ORDER BY data_pedido_date DESC NULLS LAST, id DESC)
FILTER (WHERE whatsapp_phone IS NOT NULL)
)[1] as fone,
COALESCE(SUM(quantidade * valor_unitario), 0) as total_gasto,
COALESCE(SUM(quantidade), 0) as total_comprado,
COUNT(DISTINCT COALESCE(NULLIF(pedido_id, ''), data_pedido || '_' || valor_pedido::text))::int as total_pedidos,
MAX(data_pedido_date) as ultima_compra,
ARRAY_REMOVE(ARRAY_AGG(DISTINCT whatsapp_phone), NULL) as telefones
FROM campaign_orders
WHERE data_pedido_date >= $1::date
AND data_pedido_date <= $2::date
AND whatsapp_phone IS NOT NULL
-- A campaign recipient is a WhatsApp destination, so each normalized
-- phone number must produce exactly one exported customer.
GROUP BY whatsapp_phone
ORDER BY total_gasto DESC
LIMIT $3;
`, [range.start, range.end, normalizedLimit]);
const customers = result.rows.map(row => ({
nome: row.nome,
fone: row.fone,
total_gasto: Number(row.total_gasto || 0),
total_comprado: Number(row.total_comprado || 0),
total_pedidos: Number(row.total_pedidos || 0),
ultima_compra: row.ultima_compra,
telefones: Array.isArray(row.telefones) ? row.telefones : []
}));
return {
campaign: 'top_clients',
days: range.days,
start: range.start,
end: range.end,
limit: normalizedLimit,
count: customers.length,
generated_at: new Date().toISOString(),
customers
};
};
const claimReadyCampaignItems = async () => {
const client = await pool.connect();
@@ -117,7 +280,8 @@ const getCampaignQueueRows = async () => {
};
const getCampaignQueueSummary = async () => {
const rows = await getCampaignQueueRows();
const rows = (await getCampaignQueueRows())
.filter(row => isCampaignEligibleProductName(row.base_product_name || row.nome));
return {
threshold: CAMPAIGN_DELTA_THRESHOLD,
maxAttempts: MAX_CAMPAIGN_ATTEMPTS,
@@ -134,7 +298,8 @@ const getCampaignPreview = async () => {
AND attempts < $1
ORDER BY created_at ASC, id ASC;
`, [MAX_CAMPAIGN_ATTEMPTS]);
const groups = groupCampaignRowsByBaseProduct(result.rows);
const eligibleRows = result.rows.filter(row => isCampaignEligibleProductName(row.base_product_name || row.nome));
const groups = groupCampaignRowsByBaseProduct(eligibleRows);
const readyGroups = {};
const belowThresholdGroups = {};
@@ -205,6 +370,15 @@ const updateCampaignItemsStatus = async (ids, status, errorMessage = null) => {
`, [status, errorMessage, ids]);
};
const skipIneligibleCampaignItems = async (rows) => {
const ineligibleRows = rows.filter(row => !isCampaignEligibleProductName(row.base_product_name || row.nome));
const ids = ineligibleRows.map(row => row.id);
await updateCampaignItemsStatus(ids, 'skipped', 'Produto não elegível para campanha de cliente.');
return new Set(ineligibleRows.map(row => row.base_product_name)).size;
};
const sendWhatsappCampaign = async (products, customers) => {
const response = await fetch(N8N_WHATSAPP_TRIGGER_URL, {
method: 'POST',
@@ -231,14 +405,21 @@ const processPendingStockCampaigns = async () => {
return summary;
}
const groups = groupCampaignRowsByBaseProduct(rows);
summary.skippedGroups += await skipIneligibleCampaignItems(rows);
const eligibleRows = rows.filter(row => isCampaignEligibleProductName(row.base_product_name || row.nome));
const groups = groupCampaignRowsByBaseProduct(eligibleRows);
const products = mapCampaignProducts(groups);
const ids = products.flatMap(product => product.itemIds);
const customers = await getTopBuyersAllTime();
if (!products.length) {
return summary;
}
if (!customers.length) {
await updateCampaignItemsStatus(ids, 'skipped', 'No customers with valid phone numbers found.');
summary.skippedGroups = products.length;
summary.skippedGroups += products.length;
return summary;
}
@@ -260,6 +441,7 @@ module.exports = {
enqueueStockCampaignItem,
getCampaignPreview,
getCampaignQueueSummary,
getTopClientsForCampaign,
retryCampaignItems,
processPendingStockCampaigns
};

View File

@@ -73,6 +73,10 @@ const mapConsumptionReference = (row) => ({
efficiencyPercent: row.efficiency_percent === null ? null : Number(row.efficiency_percent),
ribGPerPiece: row.rib_g_per_piece === null ? null : Number(row.rib_g_per_piece),
materialCostPerKg: row.material_cost_per_kg === null ? null : Number(row.material_cost_per_kg),
consumptionQuantity: row.consumption_quantity === null ? null : Number(row.consumption_quantity),
consumptionUnit: row.consumption_unit || '',
source: row.source || 'manual',
lastProductionOrderId: row.last_production_order_id === null ? null : Number(row.last_production_order_id),
createdAt: row.created_at,
updatedAt: row.updated_at
});
@@ -185,7 +189,8 @@ const listConsumptionReferences = async () => {
r.material_product_id, m.sku AS material_sku, m.name AS material_name,
r.color, r.general_yield, r.size_yields, r.size_areas,
r.gramature, r.efficiency_percent, r.rib_g_per_piece,
r.material_cost_per_kg, r.created_at, r.updated_at
r.material_cost_per_kg, r.consumption_quantity, r.consumption_unit,
r.source, r.last_production_order_id, r.created_at, r.updated_at
FROM consumption_references r
JOIN catalog_products p ON p.id = r.product_id
LEFT JOIN catalog_products m ON m.id = r.material_product_id
@@ -208,9 +213,10 @@ const createConsumptionReference = async (payload) => {
const calculatedYield = Object.values(sizeYields).length
? Object.values(sizeYields).reduce((total, value) => total + value, 0) / Object.values(sizeYields).length
: null;
const consumptionQuantity = normalizeNumber(payload.consumptionQuantity);
if (!generalYield && !calculatedYield) {
const error = new Error('Informe o rendimento geral ou por tamanho.');
if (!generalYield && !calculatedYield && !consumptionQuantity) {
const error = new Error('Informe o rendimento ou consumo por peça.');
error.statusCode = 400;
throw error;
}
@@ -219,9 +225,9 @@ const createConsumptionReference = async (payload) => {
INSERT INTO consumption_references (
product_id, material_product_id, color, general_yield, size_yields,
size_areas, gramature, efficiency_percent, rib_g_per_piece,
material_cost_per_kg, updated_at
material_cost_per_kg, consumption_quantity, consumption_unit, source, updated_at
)
VALUES ($1, $2, $3, $4, $5::jsonb, $6::jsonb, $7, $8, $9, $10, CURRENT_TIMESTAMP)
VALUES ($1, $2, $3, $4, $5::jsonb, $6::jsonb, $7, $8, $9, $10, $11, $12, 'manual', CURRENT_TIMESTAMP)
RETURNING id
`, [
productId,
@@ -233,7 +239,9 @@ const createConsumptionReference = async (payload) => {
normalizeNumber(payload.gramature),
normalizeNumber(payload.efficiencyPercent),
normalizeNumber(payload.ribGPerPiece),
normalizeNumber(payload.materialCostPerKg)
normalizeNumber(payload.materialCostPerKg),
consumptionQuantity,
normalizeText(payload.consumptionUnit) || null
]);
const references = await listConsumptionReferences();

View File

@@ -0,0 +1,185 @@
const { pool } = require('../db');
const SAMPLE_LIMIT = 50;
const sampleSpecs = {
catalog_categories: {
columns: ['id', 'name', 'description', 'created_at', 'updated_at'],
orderBy: ['updated_at', 'created_at', 'id']
},
catalog_products: {
columns: ['id', 'type', 'sku', 'name', 'category_id', 'composition', 'gramature', 'material_yield', 'width_cm', 'color', 'subcategory', 'sizes', 'created_at', 'updated_at'],
orderBy: ['updated_at', 'created_at', 'id']
},
consumption_references: {
columns: ['id', 'product_id', 'material_product_id', 'color', 'general_yield', 'size_yields', 'size_areas', 'gramature', 'efficiency_percent', 'rib_g_per_piece', 'material_cost_per_kg', 'consumption_quantity', 'consumption_unit', 'source', 'last_production_order_id', 'created_at', 'updated_at'],
orderBy: ['updated_at', 'created_at', 'id']
},
cutting_family_rules: {
columns: ['family_key', 'units_per_roll', 'updated_at'],
orderBy: ['family_key']
},
cutting_product_overrides: {
columns: ['product_id', 'family_key', 'color', 'size', 'product_type', 'planning_notes', 'updated_at'],
orderBy: ['updated_at', 'product_id']
},
orders: {
columns: ['id', 'pedido_id', 'data_pedido', 'data_pedido_date', 'valor_pedido', 'produto_id', 'produto_descricao', 'quantidade', 'valor_unitario', 'id_vendedor', 'nome_vendedor', 'marketplace', 'canal_venda', 'numero_ecommerce', 'created_at'],
orderBy: ['data_pedido_date', 'created_at', 'id']
},
production_order_markers: {
columns: ['id', 'production_order_id', 'label', 'color', 'created_at'],
orderBy: ['created_at', 'id']
},
production_order_components: {
columns: ['id', 'production_order_id', 'component_tiny_id', 'component_sku', 'component_name', 'quantity_per_unit', 'total_quantity', 'unit', 'created_at', 'updated_at'],
orderBy: ['updated_at', 'created_at', 'id']
},
production_order_steps: {
columns: ['id', 'production_order_id', 'step_number', 'name', 'start_date', 'end_date', 'status', 'color', 'created_at', 'updated_at'],
orderBy: ['updated_at', 'created_at', 'id']
},
product_compositions: {
columns: ['id', 'source', 'external_source_id', 'finished_product_identity', 'finished_product_sku', 'finished_product_description', 'finished_product_unit', 'finished_tiny_product_id', 'last_synced_at', 'created_at', 'updated_at'],
orderBy: ['last_synced_at', 'updated_at', 'id']
},
product_composition_components: {
columns: ['id', 'product_composition_id', 'component_identity', 'component_tiny_id', 'component_sku', 'component_name', 'quantity_per_unit', 'unit', 'created_at', 'updated_at'],
orderBy: ['updated_at', 'created_at', 'id']
},
production_orders: {
columns: ['id', 'tiny_id', 'number', 'status', 'order_reference', 'issue_date', 'expected_date', 'product_sku', 'product_description', 'quantity', 'unit', 'integration_status', 'notes', 'supplier', 'lot_code', 'roll_quantity', 'fabric_kg', 'rib_kg', 'yield_pieces_per_kg', 'created_at', 'updated_at'],
orderBy: ['updated_at', 'created_at', 'id']
},
stock: {
columns: ['produto_id', 'nome', 'saldo', 'delta_estoque', 'updated_at'],
orderBy: ['updated_at', 'produto_id']
},
stock_campaign_queue: {
columns: ['id', 'base_product_name', 'produto_id', 'nome', 'saldo', 'delta_estoque', 'status', 'attempts', 'last_error', 'created_at', 'updated_at', 'sent_at'],
orderBy: ['updated_at', 'created_at', 'id']
},
supply_fabric_plans: {
columns: ['id', 'material', 'color', 'quantity_kg', 'supplier', 'priority', 'status', 'created_at', 'updated_at'],
orderBy: ['updated_at', 'created_at', 'id']
},
supply_movements: {
columns: ['id', 'receipt_id', 'lot_id', 'type', 'category', 'product', 'quantity', 'unit', 'reason', 'created_at'],
orderBy: ['created_at', 'id']
},
supply_receipts: {
columns: ['id', 'category', 'product', 'quantity', 'unit', 'supplier', 'invoice', 'notes', 'status', 'created_at', 'updated_at', 'approved_at'],
orderBy: ['updated_at', 'created_at', 'id']
},
supply_stock_lots: {
columns: ['id', 'receipt_id', 'category', 'product', 'quantity', 'unit', 'supplier', 'invoice', 'status', 'created_at', 'updated_at'],
orderBy: ['updated_at', 'created_at', 'id']
}
};
const quoteIdentifier = (identifier) => {
if (!/^[a-z_][a-z0-9_]*$/.test(identifier)) {
throw new Error(`Unsafe database identifier: ${identifier}`);
}
return `"${identifier}"`;
};
const listPublicTables = async () => {
const result = await pool.query(`
SELECT table_name
FROM information_schema.tables
WHERE table_schema = 'public'
AND table_type = 'BASE TABLE'
ORDER BY table_name
`);
return result.rows.map(row => row.table_name);
};
const listPublicColumns = async () => {
const result = await pool.query(`
SELECT table_name, column_name
FROM information_schema.columns
WHERE table_schema = 'public'
ORDER BY table_name, ordinal_position
`);
return result.rows.reduce((columnsByTable, row) => {
if (!columnsByTable[row.table_name]) columnsByTable[row.table_name] = [];
columnsByTable[row.table_name].push(row.column_name);
return columnsByTable;
}, {});
};
const countTableRows = async (tableName) => {
const result = await pool.query(`SELECT COUNT(*)::int AS count FROM ${quoteIdentifier(tableName)}`);
return result.rows[0]?.count || 0;
};
const buildOrderClause = (spec, availableColumns) => {
const orderColumns = spec.orderBy.filter(column => availableColumns.includes(column));
if (!orderColumns.length) return '';
const clauses = orderColumns.map(column => {
const direction = column === 'family_key' || column === 'product_id' || column === 'produto_id' ? 'ASC' : 'DESC';
return `${quoteIdentifier(column)} ${direction}`;
});
return ` ORDER BY ${clauses.join(', ')}`;
};
const sampleTableRows = async (tableName, availableColumns) => {
const spec = sampleSpecs[tableName];
if (!spec) return null;
const selectedColumns = spec.columns.filter(column => availableColumns.includes(column));
if (!selectedColumns.length) return null;
const selectClause = selectedColumns.map(quoteIdentifier).join(', ');
const orderClause = buildOrderClause(spec, availableColumns);
const result = await pool.query(
`SELECT ${selectClause} FROM ${quoteIdentifier(tableName)}${orderClause} LIMIT $1`,
[SAMPLE_LIMIT]
);
return result.rows;
};
const buildDatabaseDiagnostic = async (user) => {
const [tables, columnsByTable] = await Promise.all([
listPublicTables(),
listPublicColumns()
]);
const counts = {};
const samples = {};
for (const tableName of tables) {
counts[tableName] = await countTableRows(tableName);
const sampleRows = await sampleTableRows(tableName, columnsByTable[tableName] || []);
if (sampleRows) samples[tableName] = sampleRows;
}
return {
generatedAt: new Date().toISOString(),
generatedBy: {
role: user?.role || null,
userId: user?.userId || null
},
sampleLimit: SAMPLE_LIMIT,
privacy: {
countsIncludeAllPublicTables: true,
samplesExcludeTables: ['app_users', 'client_identity_tokens'],
ordersSampleExcludesCustomerNameAndPhone: true,
productionOrdersSampleExcludesTinyPayload: true
},
counts,
samples
};
};
module.exports = {
buildDatabaseDiagnostic
};

File diff suppressed because it is too large Load Diff

View File

@@ -24,6 +24,13 @@ const normalizeKey = (value) => normalizeText(value)
const normalizeSku = (value) => normalizeText(value).toUpperCase();
const normalizeUnit = (value) => {
const unit = normalizeText(value).toLowerCase();
if (['kg', 'quilo', 'quilos'].includes(unit)) return 'kg';
if (['un', 'und', 'un.', 'unidade', 'unidades'].includes(unit)) return 'un.';
return unit || 'un.';
};
const mapReceipt = (row) => ({
id: row.id,
category: row.category,
@@ -249,7 +256,10 @@ const listConsumptionReferenceRows = async () => {
m.name AS material_name,
r.color,
r.general_yield,
r.size_yields
r.size_yields,
r.consumption_quantity,
r.consumption_unit,
r.source
FROM consumption_references r
JOIN catalog_products p ON p.id = r.product_id
LEFT JOIN catalog_products m ON m.id = r.material_product_id
@@ -259,6 +269,37 @@ const listConsumptionReferenceRows = async () => {
return result.rows;
};
// The Tiny structure is the source of truth when it exists. Consumption
// references remain useful as a fallback for legacy/manual products, but they
// must not make an imported structure invisible to purchase planning.
const listCompositionRows = async () => {
const result = await pool.query(`
SELECT
composition.finished_tiny_product_id,
composition.finished_product_sku,
component.component_tiny_id,
component.component_sku,
component.component_name,
component.quantity_per_unit,
component.unit
FROM product_compositions composition
JOIN product_composition_components component
ON component.product_composition_id = composition.id
WHERE composition.source = 'tiny_olist_v3'
ORDER BY composition.id, component.id;
`);
return result.rows;
};
const listTinyStockRows = async () => {
const result = await pool.query(`
SELECT produto_id, nome, COALESCE(saldo, 0)::numeric AS saldo
FROM stock
WHERE COALESCE(produto_id, '') <> '' OR COALESCE(nome, '') <> '';
`);
return result.rows;
};
const mergeNeedLine = (needsByMaterial, key, patch) => {
const current = needsByMaterial.get(key) || {
material: patch.material,
@@ -292,13 +333,84 @@ const mergeNeedLine = (needsByMaterial, key, patch) => {
};
const buildProjectPurchaseNeeds = async (lots, receipts) => {
const [demandRows, referenceRows] = await Promise.all([
const [demandRows, referenceRows, compositionRows, tinyStockRows] = await Promise.all([
listProjectDemandRows(),
listConsumptionReferenceRows()
listConsumptionReferenceRows(),
listCompositionRows(),
listTinyStockRows()
]);
const referencesBySku = new Map(referenceRows.map(reference => [normalizeSku(reference.product_sku), reference]));
const referencesBySku = referenceRows.reduce((references, reference) => {
const sku = normalizeSku(reference.product_sku);
if (!sku) return references;
references.set(sku, [...(references.get(sku) || []), reference]);
return references;
}, new Map());
const needsByMaterial = new Map();
const compositionsByProduct = compositionRows.reduce((compositions, component) => {
[component.finished_tiny_product_id, component.finished_product_sku]
.map(normalizeSku)
.filter(Boolean)
.forEach(identity => {
compositions.set(identity, [...(compositions.get(identity) || []), component]);
});
return compositions;
}, new Map());
// A need is keyed by the imported Tiny component identity whenever
// possible. Names are retained for display and for matching manual lots.
const componentNeedKeys = new Map();
const registerNeedKey = (identity, key) => {
const normalizedIdentity = normalizeSku(identity);
if (normalizedIdentity) componentNeedKeys.set(normalizedIdentity, key);
};
const addCompositionNeed = (component, row, productId, suggestedQuantity, quantitySold, stockQuantity) => {
const unit = normalizeUnit(component.unit);
const material = normalizeText(component.component_name)
|| normalizeText(component.component_sku)
|| normalizeText(component.component_tiny_id)
|| 'Material sem cadastro';
const quantityPerUnit = Number(component.quantity_per_unit || 0);
const tinyId = normalizeText(component.component_tiny_id);
const sku = normalizeSku(component.component_sku);
const identity = tinyId || sku || normalizeKey(material);
const key = `${unit}:${normalizeSku(identity) || normalizeKey(material)}`;
if (!Number.isFinite(quantityPerUnit) || quantityPerUnit <= 0) {
mergeNeedLine(needsByMaterial, `missing-quantity:${productId}:${identity}`, {
material: `Revisar quantidade: ${normalizeText(row.product_name) || productId}`,
plannedKg: suggestedQuantity,
priority: 'Crítico',
unit: 'un.',
source: 'tiny_composition',
missingReference: true,
product: { productId, name: normalizeText(row.product_name), suggestedQuantity, quantitySold, stockQuantity }
});
return;
}
mergeNeedLine(needsByMaterial, key, {
material,
plannedKg: suggestedQuantity * quantityPerUnit,
priority: 'Atenção',
unit,
source: 'tiny_composition',
product: {
productId,
name: normalizeText(row.product_name),
suggestedQuantity,
quantitySold,
stockQuantity,
consumptionQuantity: quantityPerUnit,
consumptionUnit: unit
}
});
registerNeedKey(tinyId, key);
registerNeedKey(sku, key);
// Lots are normally registered by material name, not Tiny ID.
registerNeedKey(material, key);
};
demandRows.forEach(row => {
const productId = normalizeSku(row.product_id);
if (!productId) return;
@@ -309,8 +421,14 @@ const buildProjectPurchaseNeeds = async (lots, receipts) => {
const suggestedQuantity = Math.max(Math.ceil(projectedDemand - stockQuantity), 0);
if (suggestedQuantity <= 0) return;
const reference = referencesBySku.get(productId);
if (!reference) {
const composition = compositionsByProduct.get(productId) || [];
if (composition.length) {
composition.forEach(component => addCompositionNeed(component, row, productId, suggestedQuantity, quantitySold, stockQuantity));
return;
}
const references = referencesBySku.get(productId) || [];
if (!references.length) {
mergeNeedLine(needsByMaterial, `missing:${productId}`, {
material: `Cadastrar consumo: ${normalizeText(row.product_name) || productId}`,
plannedKg: suggestedQuantity,
@@ -329,9 +447,35 @@ const buildProjectPurchaseNeeds = async (lots, receipts) => {
return;
}
references.forEach(reference => {
const consumptionQuantity = Number(reference.consumption_quantity || 0);
const consumptionUnit = normalizeUnit(reference.consumption_unit);
const materialName = normalizeText(reference.material_name) || normalizeText(reference.material_sku) || 'Material sem cadastro';
if (consumptionQuantity > 0) {
mergeNeedLine(needsByMaterial, `${consumptionUnit}:${normalizeKey(materialName)}`, {
material: materialName,
plannedKg: suggestedQuantity * consumptionQuantity,
priority: 'Atenção',
unit: consumptionUnit,
source: reference.source || 'project_demand',
color: reference.color,
product: {
productId,
name: normalizeText(row.product_name),
suggestedQuantity,
quantitySold,
stockQuantity,
consumptionQuantity,
consumptionUnit
}
});
return;
}
const yieldPerKg = getReferenceYield(reference);
if (!yieldPerKg) {
mergeNeedLine(needsByMaterial, `missing-yield:${productId}`, {
mergeNeedLine(needsByMaterial, `missing-yield:${productId}:${reference.material_product_id || 'material'}`, {
material: `Cadastrar rendimento: ${normalizeText(row.product_name) || productId}`,
plannedKg: suggestedQuantity,
priority: 'Crítico',
@@ -350,13 +494,12 @@ const buildProjectPurchaseNeeds = async (lots, receipts) => {
return;
}
const materialName = normalizeText(reference.material_name) || normalizeText(reference.material_sku) || 'Material sem cadastro';
mergeNeedLine(needsByMaterial, normalizeKey(materialName), {
mergeNeedLine(needsByMaterial, `kg:${normalizeKey(materialName)}`, {
material: materialName,
plannedKg: suggestedQuantity / yieldPerKg,
priority: 'Atenção',
unit: 'kg',
source: 'project_demand',
source: reference.source || 'project_demand',
color: reference.color,
product: {
productId,
@@ -368,17 +511,35 @@ const buildProjectPurchaseNeeds = async (lots, receipts) => {
}
});
});
});
const getNeedForInventory = (unit, id, name) => {
const normalizedUnit = normalizeUnit(unit);
const key = componentNeedKeys.get(normalizeSku(id)) || componentNeedKeys.get(normalizeSku(name));
return key && key.startsWith(`${normalizedUnit}:`) ? needsByMaterial.get(key) : undefined;
};
// Tiny saldo is material stock too. It is matched using the component ID
// first, then SKU/name, which lets "MALHA PRETA" stock directly reduce the
// material purchase suggestion imported from the product structure.
tinyStockRows.forEach(stock => {
const need = getNeedForInventory('un.', stock.produto_id, stock.nome)
|| getNeedForInventory('kg', stock.produto_id, stock.nome)
|| getNeedForInventory('', stock.produto_id, stock.nome);
if (need) need.stockKg += Number(stock.saldo || 0);
});
lots.forEach(lot => {
if (lot.unit !== 'kg') return;
const need = needsByMaterial.get(normalizeKey(lot.product));
if (need && need.unit === 'kg') need.stockKg += lot.quantity;
const need = getNeedForInventory(lot.unit, '', lot.product)
|| needsByMaterial.get(`${normalizeUnit(lot.unit)}:${normalizeKey(lot.product)}`);
if (need) need.stockKg += lot.quantity;
});
receipts.forEach(receipt => {
if (receipt.status !== 'pending' || receipt.unit !== 'kg') return;
const need = needsByMaterial.get(normalizeKey(receipt.product));
if (need && need.unit === 'kg') need.pendingKg += receipt.quantity;
if (receipt.status !== 'pending') return;
const need = getNeedForInventory(receipt.unit, '', receipt.product)
|| needsByMaterial.get(`${normalizeUnit(receipt.unit)}:${normalizeKey(receipt.product)}`);
if (need) need.pendingKg += receipt.quantity;
});
return Array.from(needsByMaterial.values()).map(need => {
@@ -422,9 +583,11 @@ const mergePurchaseNeeds = (manualNeeds, projectNeeds) => {
}
current.plannedKg += need.plannedKg;
current.stockKg += need.stockKg;
current.pendingKg += need.pendingKg;
current.purchaseKg += need.purchaseKg;
// Stock/receipts are the same physical inventory for a manual plan and
// an imported-composition need. They are alternatives views of the
// balance, never amounts to add together.
current.stockKg = Math.max(current.stockKg, need.stockKg);
current.pendingKg = Math.max(current.pendingKg, need.pendingKg);
current.priority = need.priority === 'Crítico' || current.priority === 'Crítico'
? 'Crítico'
: need.priority === 'Atenção' || current.priority === 'Atenção'
@@ -443,11 +606,18 @@ const mergePurchaseNeeds = (manualNeeds, projectNeeds) => {
});
return Array.from(mergedByKey.values())
.map(need => ({
.map(need => {
const purchaseKg = Math.max(need.plannedKg - need.stockKg - need.pendingKg, 0);
return {
...need,
purchaseKg,
status: purchaseKg > 0
? (need.priority === 'Crítico' || need.stockKg === 0 ? 'critical' : 'attention')
: 'ok',
suppliers: Array.from(need.suppliers),
colors: Array.from(need.colors)
}))
};
})
.sort((a, b) => {
const statusOrder = { critical: 1, attention: 2, ok: 3 };
return statusOrder[a.status] - statusOrder[b.status] || b.purchaseKg - a.purchaseKg || a.material.localeCompare(b.material);

View File

@@ -6,6 +6,7 @@ const {
formatProductNameForDisplay,
groupCampaignRows,
groupCampaignRowsByBaseProduct,
isCampaignEligibleProductName,
mapCampaignProducts
} = require('../services/campaignFormatter');
@@ -58,6 +59,39 @@ test('formatProductNameForDisplay applies customer-facing campaign aliases', ()
);
});
test('isCampaignEligibleProductName allows only customer-facing apparel campaigns', () => {
[
'Camiseta Premium Cor Bordo',
'Camiseta Premium Cor Preto',
'Moletom Canguru Premium Cor Preto',
'IMPRESSÃO DTF PERSONALIZADO 57X100 (1 METRO)',
'BASE LISA CAMISETA COR PRETO TAMANHO - G',
'BASE LISA MOLETOM CANGURU COR PRETO'
].forEach(name => {
assert.equal(isCampaignEligibleProductName(name), true, name);
});
[
'Ilhos Com Arruela',
'Atacador 001 Chato Preto 1,20 Mt',
'2099 Ribana 2x1 Cor Bordo',
'2001.09 Ribana 2x1 Cor Cinza',
'2001.09 Malha Camiseta 30oe Cor Cinza',
'201 Malha Camiseta 30oe Cor Preto',
'4006 Malha Camiseta 30oe Cor Marinho',
'TINTA DTF 1 LITRO - BRANCO',
'FILME DTF ROLO 60CM',
'POLIAMIDA EM PÓ PARA DTF - 1 KG',
'SALDO ESTOQUE CAMISETA'
].forEach(name => {
assert.equal(isCampaignEligibleProductName(name), false, name);
});
});
test('isCampaignEligibleProductName keeps accessories out of WhatsApp apparel campaigns', () => {
assert.equal(isCampaignEligibleProductName('BONÉ - PRETO'), false);
});
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' }),

View File

@@ -0,0 +1,111 @@
const assert = require('node:assert/strict');
const test = require('node:test');
const withCampaignService = async (queryHandler, callback) => {
const dbPath = require.resolve('../db');
const servicePath = require.resolve('../services/campaignService');
const originalDbCache = require.cache[dbPath];
const originalServiceCache = require.cache[servicePath];
const queries = [];
delete require.cache[servicePath];
require.cache[dbPath] = {
id: dbPath,
filename: dbPath,
loaded: true,
exports: {
pool: {
query: async (sql, params = []) => {
queries.push({ sql, params });
return queryHandler(sql, params);
}
}
}
};
try {
const service = require('../services/campaignService');
return await callback(service, queries);
} finally {
delete require.cache[servicePath];
if (originalServiceCache) {
require.cache[servicePath] = originalServiceCache;
}
if (originalDbCache) {
require.cache[dbPath] = originalDbCache;
} else {
delete require.cache[dbPath];
}
}
};
test('getTopClientsForCampaign returns top clients for an explicit date range', async () => {
await withCampaignService(async () => ({
rows: [
{
nome: 'Cliente A',
fone: '5516999999901',
total_gasto: '1234.50',
total_comprado: '18',
total_pedidos: 4,
ultima_compra: '2026-07-27',
telefones: ['5516999999901', '5516999999902']
}
]
}), async ({ getTopClientsForCampaign }, queries) => {
const result = await getTopClientsForCampaign({
start: '2026-06-28',
end: '2026-07-27',
limit: '1000'
});
assert.equal(result.start, '2026-06-28');
assert.equal(result.end, '2026-07-27');
assert.equal(result.limit, 1000);
assert.equal(result.count, 1);
assert.deepEqual(result.customers[0], {
nome: 'Cliente A',
fone: '5516999999901',
total_gasto: 1234.5,
total_comprado: 18,
total_pedidos: 4,
ultima_compra: '2026-07-27',
telefones: ['5516999999901', '5516999999902']
});
assert.equal(queries.length, 1);
assert.deepEqual(queries[0].params, ['2026-06-28', '2026-07-27', 1000]);
assert.match(queries[0].sql, /GROUP BY whatsapp_phone/);
assert.doesNotMatch(queries[0].sql, /GROUP BY COALESCE\(canonical_customer_name, whatsapp_phone\)/);
assert.match(queries[0].sql, /ARRAY_AGG\(DISTINCT whatsapp_phone\)/);
assert.match(queries[0].sql, /ORDER BY total_gasto DESC/);
});
});
test('getTopClientsForCampaign derives an inclusive 30 day range from the end date', async () => {
await withCampaignService(async () => ({ rows: [] }), async ({ getTopClientsForCampaign }, queries) => {
const result = await getTopClientsForCampaign({
days: '30',
end: '2026-07-27'
});
assert.equal(result.start, '2026-06-28');
assert.equal(result.end, '2026-07-27');
assert.equal(result.limit, 1000);
assert.deepEqual(queries[0].params, ['2026-06-28', '2026-07-27', 1000]);
});
});
test('getTopClientsForCampaign normalizes phones and groups one row per WhatsApp number', async () => {
await withCampaignService(async () => ({ rows: [] }), async ({ getTopClientsForCampaign }, queries) => {
await getTopClientsForCampaign({
days: '30',
end: '2026-07-27'
});
assert.match(queries[0].sql, /regexp_replace\(COALESCE\(cliente_fone, ''\), '\\D', '', 'g'\)/);
assert.match(queries[0].sql, /WHEN length\(/);
assert.match(queries[0].sql, /'55' \|\|/);
assert.match(queries[0].sql, /GROUP BY whatsapp_phone/);
});
});

View File

@@ -0,0 +1,374 @@
const assert = require('node:assert/strict');
const test = require('node:test');
const { pool } = require('../db');
const productionOrderRouter = require('../routes/productionOrderRoutes');
const analyticsRouter = require('../routes/analyticsRoutes');
const getRouteHandler = (router, method, path) => {
const route = router.stack.find(layer => layer.route?.path === path && layer.route.methods[method]);
if (!route) throw new Error(`Route not found: ${method.toUpperCase()} ${path}`);
return route.route.stack.at(-1).handle;
};
const tinySyncHandler = getRouteHandler(productionOrderRouter, 'post', '/production-orders/tiny-sync');
const tinyCompositionImportHandler = getRouteHandler(productionOrderRouter, 'post', '/production-orders/tiny-compositions/import');
const productCompositionHandler = getRouteHandler(analyticsRouter, 'get', '/analytics/products/:productId/composition');
const productCompositionsHandler = getRouteHandler(analyticsRouter, 'get', '/analytics/product-compositions');
const invokeHandler = async (handler, req) => {
let statusCode = 200;
let body;
const res = {
status(code) {
statusCode = code;
return this;
},
json(payload) {
body = payload;
return this;
}
};
await handler(req, res);
return { statusCode, body };
};
const compositionPayload = (components = [
{
componentTinyId: '919498232',
componentName: 'ETIQUETA DE TAMANHO GG',
componentSku: 'ETIQ.T.GG',
quantityPerUnit: '1',
totalQuantity: '1',
unit: 'UN'
},
{
componentTinyId: '976059144',
componentName: '6118 MALHA CAMISETA 30OE COR CAFE',
componentSku: 'MC.30.CAF',
quantityPerUnit: '0.1851',
totalQuantity: '0.1851',
unit: 'KG'
}
]) => ({
order: {
tinyId: 'STRUCTURE-V3-976058813',
number: 'STRUCTURE-V3-BLCS.CAF.GG',
status: 'completed',
productSku: 'BLCS.CAF.GG',
productDescription: 'BASE LISA CAMISETA COR CAFE TAMANHO - GG',
quantity: '1',
unit: 'UN',
notes: 'Composição sincronizada da API pública Olist V3.'
},
components,
steps: []
});
const withFakeDatabase = async (run) => {
const originalConnect = pool.connect;
const originalQuery = pool.query;
const state = {
nextCompositionId: 1,
nextOrderId: 1,
nextCategoryId: 1,
nextCatalogProductId: 1,
nextConsumptionReferenceId: 1,
compositions: [],
compositionComponents: [],
productionOrders: [],
categories: [],
catalogProducts: [],
consumptionReferences: []
};
const query = async (sql, params = []) => {
const normalizedSql = String(sql).replace(/\s+/g, ' ').trim();
if (/^(BEGIN|COMMIT|ROLLBACK);?$/.test(normalizedSql)) return { rows: [] };
if (normalizedSql.startsWith('INSERT INTO catalog_categories')) {
let category = state.categories.find(item => item.name === params[0]);
if (!category) {
category = { id: state.nextCategoryId++, name: params[0], description: params[1] };
state.categories.push(category);
}
return { rows: [{ id: category.id }] };
}
if (normalizedSql.startsWith('INSERT INTO catalog_products')) {
let product = state.catalogProducts.find(item => item.sku === params[1]);
if (!product) {
product = {
id: state.nextCatalogProductId++,
type: params[0],
sku: params[1],
name: params[2],
category_id: params[3],
notes: params[4]
};
state.catalogProducts.push(product);
} else {
product.type = params[0];
product.name = params[2];
product.category_id = product.category_id || params[3];
product.notes = product.notes || params[4];
}
return { rows: [{ id: product.id }] };
}
if (normalizedSql.startsWith('SELECT id FROM consumption_references')) {
const reference = state.consumptionReferences.find(item => (
item.product_id === params[0]
&& (item.material_product_id || 0) === params[1]
&& (item.color || '') === ''
&& (item.source || 'manual') === params[2]
));
return { rows: reference ? [{ id: reference.id }] : [] };
}
if (normalizedSql.startsWith('UPDATE consumption_references')) {
const reference = state.consumptionReferences.find(item => item.id === params[5]);
if (reference) {
reference.general_yield = params[0];
reference.consumption_quantity = params[1];
reference.consumption_unit = params[2];
reference.last_production_order_id = params[3];
reference.source = params[4];
}
return { rows: [] };
}
if (normalizedSql.startsWith('INSERT INTO consumption_references')) {
state.consumptionReferences.push({
id: state.nextConsumptionReferenceId++,
product_id: params[0],
material_product_id: params[1],
general_yield: params[2],
consumption_quantity: params[3],
consumption_unit: params[4],
source: params[5],
last_production_order_id: params[6]
});
return { rows: [] };
}
if (normalizedSql.startsWith('SELECT id FROM product_compositions WHERE source')) {
const composition = state.compositions.find(item => item.source === params[0] && item.finished_product_identity === params[1]);
return { rows: composition ? [{ id: composition.id }] : [] };
}
if (normalizedSql.startsWith('INSERT INTO product_compositions')) {
const composition = {
id: state.nextCompositionId++,
source: params[0],
external_source_id: params[1],
finished_product_identity: params[2],
finished_product_sku: params[3],
finished_product_description: params[4],
finished_product_unit: params[5],
finished_tiny_product_id: params[6],
source_metadata: JSON.parse(params[7]),
last_synced_at: '2026-07-29T12:00:00.000Z'
};
state.compositions.push(composition);
return { rows: [{ id: composition.id }] };
}
if (normalizedSql.startsWith('UPDATE product_compositions')) {
const composition = state.compositions.find(item => item.id === params[7]);
composition.external_source_id = params[0];
composition.finished_product_sku = params[2];
composition.finished_product_description = params[3];
composition.finished_product_unit = params[4];
composition.finished_tiny_product_id = params[5];
composition.source_metadata = JSON.parse(params[6]);
return { rows: [] };
}
if (normalizedSql.startsWith('DELETE FROM product_composition_components')) {
state.compositionComponents = state.compositionComponents.filter(item => item.product_composition_id !== params[0]);
return { rows: [] };
}
if (normalizedSql.startsWith('INSERT INTO product_composition_components')) {
state.compositionComponents.push({
id: state.compositionComponents.length + 1,
product_composition_id: params[0],
component_identity: params[1],
component_tiny_id: params[2],
component_sku: params[3],
component_name: params[4],
quantity_per_unit: params[5],
unit: params[6]
});
return { rows: [] };
}
if (normalizedSql.includes('FROM product_compositions composition')) {
const compositions = state.compositions.filter(item => (
item.source === params[0]
&& (params[1] === '' || item.finished_tiny_product_id === params[1] || item.finished_product_sku === String(params[1]).toUpperCase())
));
return {
rows: compositions.map(composition => ({
...composition,
components: state.compositionComponents
.filter(item => item.product_composition_id === composition.id)
.map(item => ({ ...item, component_product_id: null }))
}))
};
}
if (normalizedSql.startsWith('SELECT id FROM production_orders')) {
return { rows: [] };
}
if (normalizedSql.startsWith('INSERT INTO production_orders')) {
const order = {
id: state.nextOrderId++,
tiny_id: params[0], number: params[1], status: params[2], order_reference: params[3],
issue_date: params[4], expected_date: params[5], product_sku: params[6],
product_description: params[7], quantity: params[8], unit: params[9],
integration_status: params[10], notes: params[11], supplier: params[12], lot_code: params[13],
roll_quantity: params[14], fabric_kg: params[15], rib_kg: params[16],
yield_pieces_per_kg: params[17], created_at: null, updated_at: null
};
state.productionOrders.push(order);
return { rows: [{ id: order.id }] };
}
if (normalizedSql.startsWith('DELETE FROM production_order_components') || normalizedSql.startsWith('DELETE FROM production_order_steps')) {
return { rows: [] };
}
if (normalizedSql.includes('FROM production_orders po') && normalizedSql.includes('WHERE po.id = $1')) {
const order = state.productionOrders.find(item => item.id === params[0]);
return { rows: order ? [{ ...order, markers: [], components: [], steps: [] }] : [] };
}
throw new Error(`Unexpected SQL in test double: ${normalizedSql.slice(0, 100)}`);
};
pool.query = query;
pool.connect = async () => ({ query, release() {} });
try {
await run(state);
} finally {
pool.connect = originalConnect;
pool.query = originalQuery;
}
};
test('Tiny/Olist V3 first import creates a composition without creating an OP', async () => {
await withFakeDatabase(async (state) => {
const response = await invokeHandler(tinySyncHandler, { body: compositionPayload() });
const result = response.body;
assert.equal(response.statusCode, 201);
assert.equal(result.componentCount, 2);
assert.equal(state.compositions.length, 1);
assert.equal(state.compositionComponents.length, 2);
assert.equal(state.productionOrders.length, 0);
assert.equal(state.consumptionReferences.length, 2);
assert.equal(result.composition.finishedTinyProductId, '976058813');
});
});
test('Tiny/Olist V3 bulk import stores compositions and consumption references', async () => {
await withFakeDatabase(async (state) => {
const response = await invokeHandler(tinyCompositionImportHandler, {
body: {
exportedAt: '2026-07-31T13:37:01.340Z',
compositions: [{
finishedProductSku: 'BLCS.CAF.GG',
finishedProductDescription: 'BASE LISA CAMISETA COR CAFE TAMANHO - GG',
finishedProductUnit: 'UN',
finishedTinyProductId: '976058813',
sourceMetadata: { payload: compositionPayload() },
components: []
}]
}
});
assert.equal(response.statusCode, 201);
assert.equal(response.body.imported, 1);
assert.equal(response.body.referenceCount, 2);
assert.equal(state.compositions.length, 1);
assert.equal(state.consumptionReferences.length, 2);
assert.equal(state.consumptionReferences[0].source, 'tiny_structure');
});
});
test('Tiny/Olist V3 re-sync replaces composition components without duplicates', async () => {
await withFakeDatabase(async (state) => {
await invokeHandler(tinySyncHandler, { body: compositionPayload() });
await invokeHandler(tinySyncHandler, { body: compositionPayload([
{
componentTinyId: '976059144', componentName: 'MALHA ATUALIZADA', componentSku: 'MC.30.CAF',
quantityPerUnit: '0.2', totalQuantity: '0.2', unit: 'KG'
},
{
componentTinyId: '976059144', componentName: 'MALHA ATUALIZADA', componentSku: 'MC.30.CAF',
quantityPerUnit: '0.2', totalQuantity: '0.2', unit: 'KG'
}
]) });
assert.equal(state.compositions.length, 1);
assert.equal(state.compositionComponents.length, 1);
assert.equal(state.compositionComponents[0].quantity_per_unit, 0.2);
assert.equal(state.productionOrders.length, 0);
});
});
test('product composition detail lookup returns stored components', async () => {
await withFakeDatabase(async () => {
await invokeHandler(tinySyncHandler, { body: compositionPayload() });
const response = await invokeHandler(productCompositionHandler, { params: { productId: '976058813' } });
const composition = response.body.composition;
assert.equal(response.statusCode, 200);
assert.equal(composition.finishedProductSku, 'BLCS.CAF.GG');
assert.equal(composition.components.length, 2);
assert.deepEqual(composition.components[0], {
id: 1,
componentTinyId: '919498232',
componentSku: 'ETIQ.T.GG',
componentName: 'ETIQUETA DE TAMANHO GG',
quantityPerUnit: 1,
unit: 'UN',
productId: null
});
});
});
test('product composition export endpoint returns every stored composition', async () => {
await withFakeDatabase(async () => {
await invokeHandler(tinySyncHandler, { body: compositionPayload() });
const response = await invokeHandler(productCompositionsHandler, { params: {} });
assert.equal(response.statusCode, 200);
assert.equal(response.body.compositions.length, 1);
assert.equal(response.body.compositions[0].components.length, 2);
});
});
test('ordinary Tiny production-order payloads continue to create production orders', async () => {
await withFakeDatabase(async (state) => {
const response = await invokeHandler(tinySyncHandler, { body: {
order: {
tinyId: '12345', number: 'OP-12345', status: 'in_progress', productSku: 'BLCS.CAF.GG',
productDescription: 'BASE LISA CAMISETA COR CAFE TAMANHO - GG', quantity: '10', unit: 'UN'
},
components: [],
steps: []
} });
const result = response.body;
assert.equal(response.statusCode, 201);
assert.equal(state.compositions.length, 0);
assert.equal(state.productionOrders.length, 1);
assert.equal(result.order.tinyId, '12345');
assert.equal(result.order.number, 'OP-12345');
});
});

View File

@@ -27,6 +27,11 @@ services:
- ADMIN_PASSWORD=${ADMIN_PASSWORD:-admin123}
- JWT_SECRET=${JWT_SECRET:-super_secret_jwt_key_123}
- N8N_WHATSAPP_TRIGGER_URL=${N8N_WHATSAPP_TRIGGER_URL:-http://localhost:5678/webhook/whatsapp}
- TURNSTILE_SITE_KEY=${TURNSTILE_SITE_KEY:-}
- TURNSTILE_SITEKEY=${TURNSTILE_SITEKEY:-}
- VITE_TURNSTILE_SITE_KEY=${VITE_TURNSTILE_SITE_KEY:-}
- TURNSTILE_SECRET=${TURNSTILE_SECRET:-}
- TURNSTILE_SECRET_KEY=${TURNSTILE_SECRET_KEY:-}
depends_on:
- db
restart: unless-stopped

View File

@@ -15,6 +15,8 @@ const ProductionOrders = React.lazy(() => import('./pages/ProductionOrders'));
const Supplies = React.lazy(() => import('./pages/Supplies'));
const Clients = React.lazy(() => import('./pages/Clients'));
const ClientDetails = React.lazy(() => import('./pages/ClientDetails'));
const Sellers = React.lazy(() => import('./pages/Sellers'));
const SellerDetails = React.lazy(() => import('./pages/SellerDetails'));
const Campaigns = React.lazy(() => import('./pages/Campaigns'));
const Rfm = React.lazy(() => import('./pages/Rfm'));
const Registrations = React.lazy(() => import('./pages/Registrations'));
@@ -63,6 +65,8 @@ function App() {
<Route path="production-orders" element={<ProductionOrders />} />
<Route path="clients" element={<Clients />} />
<Route path="clients/:clientToken" element={<ClientDetails />} />
<Route path="sellers" element={<Sellers />} />
<Route path="sellers/:sellerId" element={<SellerDetails />} />
<Route path="rfm" element={<Rfm />} />
<Route path="campaigns" element={<Campaigns />} />
<Route path="registrations" element={<Registrations />} />

View File

@@ -1,6 +1,7 @@
import type { CutFamilyKey, CutProductOverride, DateRange, ProductAnalyticsItem, ProductionOrderItem } from '../types';
import { normalizeProductText, parseProductName, sortProductSizes } from '../productParsing.ts';
import { resolveProductType } from '../productClassification.ts';
import { getPlanningStock } from '../planningStock.ts';
export type { CutFamilyKey, CutProductOverride };
@@ -216,7 +217,7 @@ export const buildCutPlan = (
const dailySales = product.quantitySold / rangeDays;
const projectedDemand = dailySales * targetCoverageDays;
const openProductionQuantity = openProductionByProductId[product.id] || 0;
const availableQuantity = product.stock + openProductionQuantity;
const availableQuantity = getPlanningStock(product.stock) + openProductionQuantity;
const suggestedCutQuantity = Math.max(0, Math.ceil(projectedDemand - availableQuantity));
const estimatedRolls = family.unitsPerRoll && suggestedCutQuantity > 0
? Math.ceil(suggestedCutQuantity / family.unitsPerRoll)

View File

@@ -94,12 +94,12 @@ const DateRangePicker: React.FC<DateRangePickerProps> = ({ dateRange, onChange,
};
return (
<div className="flex flex-wrap items-center gap-3">
<div className="flex w-full flex-col gap-2 sm:w-auto sm:flex-row sm:flex-wrap sm:items-center sm:gap-3">
{/* Date Presets Dropdown */}
<div className="relative">
<div className="relative w-full sm:w-auto">
<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"
className="flex min-h-11 w-full items-center gap-2 rounded-xl border border-dark-border bg-dark-card px-4 py-2.5 text-sm font-medium text-dark-text shadow-sm transition-colors hover:border-brand-primary cursor-pointer sm:w-auto"
>
<Calendar size={16} className="text-dark-muted shrink-0" />
<span>{formatDisplayDate(dateRange.start)} - {formatDisplayDate(dateRange.end)}</span>
@@ -109,7 +109,7 @@ const DateRangePicker: React.FC<DateRangePickerProps> = ({ dateRange, onChange,
{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="fixed inset-x-3 top-20 z-20 mt-0 max-h-[calc(100dvh-6rem)] overflow-y-auto rounded-xl border border-dark-border bg-dark-card py-2 shadow-xl sm:absolute sm:right-0 sm:top-auto sm:inset-x-auto sm:mt-2 sm:max-h-[32rem] sm:w-56">
<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">
@@ -177,7 +177,7 @@ const DateRangePicker: React.FC<DateRangePickerProps> = ({ dateRange, onChange,
{/* 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">
<div className="flex min-h-11 w-full items-center overflow-hidden rounded-xl border border-dark-border bg-dark-card shadow-sm transition-colors hover:border-brand-primary sm:w-auto">
<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"

View File

@@ -1,6 +1,6 @@
import { useState, useEffect } from 'react';
import { Outlet, Link, useLocation } from 'react-router-dom';
import { LayoutDashboard, Users, BarChart3, ChevronLeft, ChevronRight, Package, LogOut, Megaphone, Grid3X3, Shield, Moon, Sun, Tags, Boxes, ClipboardList } from 'lucide-react';
import { LayoutDashboard, Users, BarChart3, ChevronLeft, ChevronRight, Package, LogOut, Megaphone, Grid3X3, Shield, Moon, Sun, Tags, Boxes, ClipboardList, Menu, X, BriefcaseBusiness } from 'lucide-react';
import type { DateRange, OrderData } from '../types';
import { isSuperAdmin, logout } from '../dataService';
import { rangeForLastDays } from '../dateRanges';
@@ -13,6 +13,7 @@ const Layout = () => {
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(() => {
return localStorage.getItem('graph_sidebar_collapsed') === 'true';
});
const [isMobileNavOpen, setIsMobileNavOpen] = useState(false);
const [themeMode, setThemeMode] = useState<ThemeMode>(() => {
return localStorage.getItem('nexstar_theme') === 'offwhite' ? 'offwhite' : 'dark';
});
@@ -58,6 +59,7 @@ const Layout = () => {
const toggleTheme = () => {
setThemeMode(current => current === 'dark' ? 'offwhite' : 'dark');
};
const showSidebarLabels = !isSidebarCollapsed || isMobileNavOpen;
const adminNavigation = isSuperAdmin()
? [{ name: 'Usuários', href: '/admin/users', icon: Shield }]
@@ -82,6 +84,7 @@ const Layout = () => {
label: 'Comercial',
items: [
{ name: 'Clientes', href: '/clients', icon: Users },
{ name: 'Vendedores', href: '/sellers', icon: BriefcaseBusiness },
{ name: 'RFV', href: '/rfm', icon: Grid3X3 },
{ name: 'Campanhas', href: '/campaigns', icon: Megaphone },
],
@@ -90,25 +93,36 @@ const Layout = () => {
];
return (
<div className="app-shell flex h-screen text-dark-text overflow-hidden">
<div className="app-shell flex h-dvh min-w-0 text-dark-text overflow-hidden">
{isMobileNavOpen && (
<button
type="button"
className="fixed inset-0 z-40 bg-black/55 md:hidden"
onClick={() => setIsMobileNavOpen(false)}
aria-label="Fechar menu"
/>
)}
{/* Sidebar */}
<aside className={`bg-dark-sidebar border-r border-dark-border flex flex-col transition-all duration-300 ${isSidebarCollapsed ? 'w-20' : 'w-64'}`}>
<div className={`h-20 px-6 border-b border-dark-border flex items-center ${isSidebarCollapsed ? 'justify-center' : 'justify-between'}`}>
<aside className={`fixed inset-y-0 left-0 z-50 flex w-72 flex-col border-r border-dark-border bg-dark-sidebar transition-transform duration-300 md:relative md:z-auto md:translate-x-0 md:transition-[width] ${isSidebarCollapsed ? 'md:w-20' : 'md:w-64'} ${isMobileNavOpen ? 'translate-x-0' : '-translate-x-full'}`}>
<div className={`flex h-16 items-center border-b border-dark-border px-5 md:h-20 md:px-6 ${isSidebarCollapsed ? 'md:justify-center' : 'justify-between'}`}>
<div className="flex items-center gap-2">
<div className="rounded-lg border border-brand-primary/25 bg-brand-primary/15 p-1.5 text-brand-primary shadow-sm shadow-brand-primary/10">
<BarChart3 className="w-6 h-6" />
</div>
{!isSidebarCollapsed && <span className="text-xl font-bold text-dark-text">Nexstar</span>}
{showSidebarLabels && <span className="text-xl font-bold text-dark-text">Nexstar</span>}
</div>
<button onClick={() => setIsMobileNavOpen(false)} className="text-dark-muted transition-colors hover:text-dark-text md:hidden" aria-label="Fechar menu">
<X className="h-5 w-5" />
</button>
{!isSidebarCollapsed && (
<button onClick={toggleSidebar} className="text-dark-muted hover:text-dark-text transition-colors cursor-pointer">
<button onClick={toggleSidebar} className="hidden cursor-pointer text-dark-muted transition-colors hover:text-dark-text md:block">
<ChevronLeft className="w-5 h-5" />
</button>
)}
</div>
{isSidebarCollapsed && (
<div className="p-4 border-b border-dark-border flex justify-center">
<div className="hidden border-b border-dark-border p-4 md:flex md:justify-center">
<button onClick={toggleSidebar} className="text-dark-muted hover:text-dark-text transition-colors cursor-pointer">
<ChevronRight className="w-5 h-5" />
</button>
@@ -120,10 +134,10 @@ const Layout = () => {
<div
key={section.label}
className={`space-y-1.5 ${
isSidebarCollapsed ? 'border-t border-dark-border pt-5 first:border-t-0 first:pt-0' : ''
isSidebarCollapsed ? 'md:border-t md:border-dark-border md:pt-5 md:first:border-t-0 md:first:pt-0' : ''
}`}
>
{!isSidebarCollapsed && (
{showSidebarLabels && (
<div className="px-3 pb-1 text-[11px] font-bold uppercase tracking-widest text-dark-muted">
{section.label}
</div>
@@ -134,8 +148,9 @@ const Layout = () => {
<Link
key={item.name}
to={item.href}
onClick={() => setIsMobileNavOpen(false)}
className={`group flex min-h-11 items-center rounded-lg px-3 py-2.5 text-sm transition-all ${
isSidebarCollapsed ? 'justify-center' : 'gap-3'
isSidebarCollapsed ? 'gap-3 md:justify-center' : 'gap-3'
} ${
isActive
? 'bg-brand-primary/12 text-brand-primary font-semibold shadow-sm shadow-brand-primary/10 ring-1 ring-brand-primary/15'
@@ -144,7 +159,7 @@ const Layout = () => {
title={isSidebarCollapsed ? item.name : undefined}
>
<item.icon className="h-4.5 w-4.5 shrink-0" />
{!isSidebarCollapsed && <span className="truncate font-medium">{item.name}</span>}
{showSidebarLabels && <span className="truncate font-medium">{item.name}</span>}
</Link>
);
})}
@@ -155,23 +170,33 @@ const Layout = () => {
<div className="p-4 border-t border-dark-border">
<button
onClick={logout}
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'}`}
className={`flex w-full items-center rounded-xl px-4 py-3 text-red-500 transition-all hover:bg-red-500/10 cursor-pointer ${isSidebarCollapsed ? 'gap-3 md:justify-center' : 'gap-3'}`}
>
<LogOut className="w-5 h-5 shrink-0" />
{!isSidebarCollapsed && <span className="font-medium">Sair</span>}
{showSidebarLabels && <span className="font-medium">Sair</span>}
</button>
</div>
</aside>
{/* Main Content */}
<main className="flex-1 flex flex-col h-screen overflow-hidden">
<main className="flex min-w-0 flex-1 flex-col overflow-hidden">
{/* Header */}
<header className="h-20 bg-dark-header border-b border-dark-border flex items-center justify-between px-8 shrink-0">
<h2 className="text-xl font-bold text-dark-text">Painel de Análise</h2>
<header className="flex h-16 shrink-0 items-center justify-between border-b border-dark-border bg-dark-header px-4 md:h-20 md:px-8">
<div className="flex min-w-0 items-center gap-3">
<button
type="button"
onClick={() => setIsMobileNavOpen(true)}
className="inline-flex h-11 w-11 shrink-0 items-center justify-center rounded-xl border border-dark-border bg-dark-input text-dark-text md:hidden"
aria-label="Abrir menu"
>
<Menu className="h-5 w-5" />
</button>
<h2 className="truncate text-lg font-bold text-dark-text md:text-xl">Painel de Análise</h2>
</div>
<button
type="button"
onClick={toggleTheme}
className="inline-flex h-10 w-10 items-center justify-center rounded-xl border border-transparent bg-transparent text-dark-text transition-colors hover:border-dark-border hover:bg-dark-input hover:text-brand-primary cursor-pointer"
className="inline-flex h-11 w-11 shrink-0 items-center justify-center rounded-xl border border-transparent bg-transparent text-dark-text transition-colors hover:border-dark-border hover:bg-dark-input hover:text-brand-primary cursor-pointer"
title={themeMode === 'dark' ? 'Usar modo off-white' : 'Usar modo escuro'}
aria-label={themeMode === 'dark' ? 'Usar modo off-white' : 'Usar modo escuro'}
>
@@ -180,7 +205,7 @@ const Layout = () => {
</header>
{/* Content Area */}
<div className="app-content flex-1 overflow-y-auto p-8 relative">
<div className="app-content relative flex-1 overflow-y-auto px-4 py-5 pb-24 sm:px-5 md:p-8">
<Outlet context={{ dateRange, setDateRange, ordersData: emptyOrdersData, isDataLoading: false, refreshInterval, setRefreshInterval, themeMode }} />
</div>
</main>

View File

@@ -1,4 +1,4 @@
import type { AuthUser, CampaignPreview, CampaignProcessSummary, CampaignQueueSummary, CatalogCategory, CatalogCategoryPayload, CatalogProduct, CatalogProductPayload, CatalogSummary, ClientAnalyticsItem, ClientDetailsAnalytics, ClientFilterOptions, ClientMetadataFilters, ClientPurchasePatternAnalytics, ConsumptionReference, ConsumptionReferencePayload, CreateUserResult, CuttingSettings, DashboardAnalytics, DateRange, ManagedUser, OrderData, ProductAnalyticsItem, ProductDetailsAnalytics, ProductionOrderSummary, RfmAnalytics, StockData, SupplyFabricPlan, SupplyFabricPlanPayload, SupplyInventoryAdjustmentPayload, SupplyLot, SupplyProductionExitPayload, SupplyPurchaseNeed, SupplyReceipt, SupplyReceiptPayload, SupplySummary } from './types';
import type { AuthUser, CampaignPreview, CampaignProcessSummary, CampaignQueueSummary, CatalogCategory, CatalogCategoryPayload, CatalogProduct, CatalogProductPayload, CatalogSummary, ClientAnalyticsItem, ClientDetailsAnalytics, ClientFilterOptions, ClientMetadataFilters, ClientPurchasePatternAnalytics, ConsumptionReference, ConsumptionReferencePayload, CreateProductionOrdersResult, CreateUserResult, CuttingSettings, DashboardAnalytics, DateRange, ManagedUser, OrderData, ProductAnalyticsItem, ProductComposition, ProductCompositionImportSummary, ProductDetailsAnalytics, ProductionOrderItem, ProductionOrderPayload, ProductionOrderStatus, ProductionOrderSummary, RfmAnalytics, SellerAnalyticsItem, SellerDetailsAnalytics, StockData, SupplyFabricPlan, SupplyFabricPlanPayload, SupplyInventoryAdjustmentPayload, SupplyLot, SupplyProductionExitPayload, SupplyPurchaseNeed, SupplyReceipt, SupplyReceiptPayload, SupplySummary } from './types';
import { formatDateParam } from './dateRanges';
const API_URL = import.meta.env.VITE_API_URL || '/api';
@@ -55,27 +55,42 @@ const buildDateRangeParams = (dateRange: DateRange) => new URLSearchParams({
end: formatDateParam(dateRange.end)
});
export const login = async (email: string, password: string): Promise<boolean> => {
export type LoginConfig = {
captchaRequired: boolean;
captchaConfigured: boolean;
turnstileSiteKey: string;
};
export type LoginResult = 'success' | 'invalid_credentials' | 'captcha_failed' | 'server_error';
export const getLoginConfig = async (): Promise<LoginConfig> => {
const response = await fetch(`${API_URL}/login/config`, { cache: 'no-store' });
if (!response.ok) throw new Error('Failed to load login config');
return response.json() as Promise<LoginConfig>;
};
export const login = async (email: string, password: string, captchaToken?: string): Promise<LoginResult> => {
try {
const response = await fetch(`${API_URL}/login`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ email, password }),
body: JSON.stringify({ email, password, captchaToken }),
});
if (response.ok) {
const data = await response.json() as { token?: string; user?: AuthUser };
if (!data.token || !data.user) return false;
if (!data.token || !data.user) return 'server_error';
localStorage.setItem('auth_token', data.token);
localStorage.setItem('auth_user', JSON.stringify(data.user));
return true;
return 'success';
}
return false;
if (response.status === 403) return 'captcha_failed';
return 'invalid_credentials';
} catch (error) {
console.error('Login failed', error);
return false;
return 'server_error';
}
};
@@ -161,6 +176,23 @@ const authFetch = async (path: string, options: RequestInit = {}): Promise<Respo
return response;
};
export const downloadDatabaseDiagnostic = async (): Promise<void> => {
const response = await authFetch('/admin/database-diagnostic', { cache: 'no-store' });
const data = await response.json().catch(() => null);
if (!response.ok) {
throw new Error(data?.error || 'Não foi possível exportar o diagnóstico do banco.');
}
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const anchor = document.createElement('a');
anchor.href = url;
anchor.download = `graphs-db-diagnostic-${new Date().toISOString().slice(0, 10)}.json`;
anchor.click();
URL.revokeObjectURL(url);
};
export const fetchProductionOrders = async (
dateRange: DateRange,
filters?: { search?: string },
@@ -183,6 +215,30 @@ export const fetchProductionOrders = async (
}, options);
};
export const createProductionOrders = async (orders: ProductionOrderPayload[]): Promise<CreateProductionOrdersResult> => {
const response = await authFetch('/production-orders', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ orders })
});
if (!response.ok) throw new Error('Não foi possível criar as ordens de produção.');
analyticsCache.clear();
return await response.json();
};
export const updateProductionOrderStatus = async (id: number, status: ProductionOrderStatus): Promise<ProductionOrderItem> => {
const response = await authFetch(`/production-orders/${id}/status`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status })
});
if (!response.ok) throw new Error('Não foi possível atualizar o status da ordem.');
analyticsCache.clear();
return await response.json();
};
export const fetchCuttingSettings = async (): Promise<CuttingSettings> => {
try {
const response = await authFetch('/cutting-settings');
@@ -289,6 +345,22 @@ export const deleteConsumptionReference = async (id: number): Promise<void> => {
}
};
export const importProductCompositions = async (payload: unknown): Promise<ProductCompositionImportSummary> => {
const response = await authFetch('/catalog/product-compositions/import', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
const data = await response.json().catch(() => null);
if (!response.ok) {
throw new Error(data?.error || 'Não foi possível importar as composições.');
}
analyticsCache.clear();
return data as ProductCompositionImportSummary;
};
export const fetchSupplySummary = async (): Promise<SupplySummary> => {
const emptySummary: SupplySummary = {
receipts: [],
@@ -469,6 +541,34 @@ export const fetchProductAnalytics = async (dateRange: DateRange): Promise<Produ
});
};
export const fetchSellerAnalytics = async (dateRange: DateRange): Promise<SellerAnalyticsItem[]> => {
const path = `/analytics/sellers?${buildDateRangeParams(dateRange).toString()}`;
return getCachedAnalytics(path, async () => {
try {
const response = await authFetch(path);
if (!response.ok) return [];
return await response.json();
} catch (error) {
console.error('Fetch seller analytics failed', error);
return [];
}
});
};
export const fetchSellerDetailsAnalytics = async (sellerId: string, dateRange: DateRange): Promise<SellerDetailsAnalytics | null> => {
const path = `/analytics/sellers/${encodeURIComponent(sellerId)}/details?${buildDateRangeParams(dateRange).toString()}`;
return getCachedAnalytics(path, async () => {
try {
const response = await authFetch(path);
if (!response.ok) return null;
return await response.json();
} catch (error) {
console.error('Fetch seller details analytics failed', error);
return null;
}
});
};
export const fetchProductDetailsAnalytics = async (productId: string, dateRange: DateRange): Promise<ProductDetailsAnalytics | null> => {
const path = `/analytics/products/${encodeURIComponent(productId)}/details?${buildDateRangeParams(dateRange).toString()}`;
return getCachedAnalytics(path, async () => {
@@ -483,6 +583,54 @@ export const fetchProductDetailsAnalytics = async (productId: string, dateRange:
});
};
export const fetchProductComposition = async (productId: string): Promise<ProductComposition | null> => {
try {
const response = await authFetch(`/analytics/products/${encodeURIComponent(productId)}/composition`);
if (!response.ok) return null;
const data = await response.json() as { composition?: ProductComposition | null };
return data.composition || null;
} catch (error) {
console.error('Fetch product composition failed', error);
return null;
}
};
export const fetchProductCompositions = async (): Promise<ProductComposition[]> => {
try {
const response = await authFetch('/analytics/product-compositions');
if (!response.ok) return [];
const data = await response.json() as { compositions?: ProductComposition[] };
return data.compositions || [];
} catch (error) {
console.error('Fetch product compositions failed', error);
return [];
}
};
export const exportProductCompositions = async (): Promise<number> => {
const response = await authFetch('/analytics/product-compositions');
const data = await response.json().catch(() => null) as { compositions?: ProductComposition[]; error?: string } | null;
if (!response.ok) {
throw new Error(data?.error || 'Não foi possível exportar as composições.');
}
const compositions = data?.compositions || [];
const blob = new Blob([JSON.stringify({
exportedAt: new Date().toISOString(),
compositions
}, null, 2)], { type: 'application/json;charset=utf-8' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `composicoes_produtos_${new Date().toISOString().slice(0, 10)}.json`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
return compositions.length;
};
const appendClientMetadataFilterParams = (params: URLSearchParams, filters?: Partial<ClientMetadataFilters>) => {
if (!filters) return;
if (filters.marketplace) params.set('marketplace', filters.marketplace);

View File

@@ -149,6 +149,36 @@
linear-gradient(180deg, rgba(255, 255, 255, 0.34), rgba(255, 255, 255, 0) 16rem),
transparent;
}
@media (max-width: 767px) {
html,
body,
#root {
min-width: 0;
overflow-x: hidden;
}
.app-content {
scroll-padding-bottom: 5rem;
-webkit-overflow-scrolling: touch;
}
.app-content .overflow-x-auto {
overscroll-behavior-x: contain;
scrollbar-width: thin;
scrollbar-color: var(--color-dark-border) transparent;
}
.app-content .overflow-x-auto::-webkit-scrollbar {
height: 5px;
}
.app-content input,
.app-content select,
.app-content textarea {
font-size: 16px;
}
}
}
@layer utilities {

View File

@@ -1,6 +1,6 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { Link, useOutletContext } from 'react-router-dom';
import { Search, ChevronRight, Filter, X } from 'lucide-react';
import { Search, Eye, Filter, X } from 'lucide-react';
import { BarChart, Bar, Cell, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
import PaginationControls from '../components/PaginationControls';
import RefreshStatus from '../components/RefreshStatus';
@@ -794,10 +794,11 @@ const Clients = () => {
<td className="px-6 py-2.5 text-right">
<Link
to={`/clients/${encodeURIComponent(client.clientToken)}`}
className="inline-flex items-center text-xs font-bold text-brand-primary hover:opacity-80 transition-opacity cursor-pointer"
className="inline-flex h-8 w-8 items-center justify-center rounded-lg bg-brand-primary/10 text-brand-primary transition-opacity hover:opacity-80 cursor-pointer"
title={`Ver detalhes de ${client.name}`}
aria-label={`Ver detalhes de ${client.name}`}
>
Ver detalhes
<ChevronRight className="w-3.5 h-3.5 ml-1" />
<Eye className="h-3.5 w-3.5" />
</Link>
</td>
</tr>

View File

@@ -1,14 +1,15 @@
import { useEffect, useMemo, useState } from 'react';
import { Link, useOutletContext, useSearchParams } from 'react-router-dom';
import { AlertTriangle, ArrowLeft, ClipboardList, Download, Eye, Layers3, Palette, Pencil, RotateCcw, Ruler, Save as SaveIcon, Scissors, Search, Settings2, X } from 'lucide-react';
import { AlertTriangle, ArrowLeft, ClipboardList, Download, Eye, Layers3, Pencil, RotateCcw, Ruler, Save as SaveIcon, Scissors, Search, Settings2, X } from 'lucide-react';
import DateRangePicker from '../components/DateRangePicker';
import PaginationControls from '../components/PaginationControls';
import ProductColorBadge from '../components/ProductColorBadge';
import RefreshStatus from '../components/RefreshStatus';
import { buildCuttingSkuConfigPath } from '../catalogLinks';
import { CUT_FAMILY_RULES, buildCutPlan, buildOpenProductionByProductId, type CutFamilyKey, type CutIssue, type CutPlanSkuRow, type CutProductOverride } from '../analytics/cutting';
import { exportToCSV, fetchCuttingSettings, fetchProductAnalytics, fetchProductionOrders, saveCuttingSettings } from '../dataService';
import type { CuttingSettings, DateRange, ProductAnalyticsItem, ProductionOrderItem } from '../types';
import { createProductionOrders, exportToCSV, fetchCuttingSettings, fetchProductAnalytics, fetchProductCompositions, fetchProductionOrders, fetchStock, saveCuttingSettings } from '../dataService';
import type { CuttingSettings, DateRange, ProductAnalyticsItem, ProductComposition, ProductionOrderItem, StockData } from '../types';
import { getPlanningStock } from '../planningStock';
type CutFilter = 'need' | 'all' | 'issues' | 'covered';
type CutSort = 'need_desc' | 'need_asc' | 'demand_desc' | 'stock_asc' | 'sold_desc' | 'name_asc';
@@ -16,6 +17,8 @@ type SaveStatus = 'idle' | 'saving' | 'saved' | 'error';
type CutProductIssue = Exclude<CutIssue, 'missing_yield_rule'>;
type CorrectionIssueFilter = CutProductIssue | 'all';
type SettingsSection = 'rules' | 'corrections';
type CuttingView = 'plan' | 'families' | 'issues';
type MaterialReadiness = { status: 'ready' | 'material_ready' | 'blocked' | 'missing'; blockers: string[] };
const SETTINGS_STORAGE_KEY = 'nexstar_cutting_settings';
const coverageTargetOptions = [7, 15, 30, 60];
@@ -65,6 +68,22 @@ const formatNumber = (value: number, maximumFractionDigits = 0) => (
new Intl.NumberFormat('pt-BR', { maximumFractionDigits }).format(value)
);
const normalizeMaterialKey = (value: string) => value
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/\s+/g, ' ')
.trim()
.toUpperCase();
const isServiceComponent = (value: string) => /\b(?:SERVI[CÇ]O|FRETE|TINTURARIA|TECELAGEM|COSTURA|ESTAMPARIA|LAVANDERIA)\b/i.test(value);
const formatDateKey = (date: Date) => {
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}`;
};
const formatDays = (value: number | null) => {
if (value === null) return '-';
if (value > 999) return '999+ dias';
@@ -120,6 +139,8 @@ const Cutting = () => {
const [searchParams] = useSearchParams();
const [products, setProducts] = useState<ProductAnalyticsItem[]>([]);
const [productionOrders, setProductionOrders] = useState<ProductionOrderItem[]>([]);
const [compositions, setCompositions] = useState<ProductComposition[]>([]);
const [materialStock, setMaterialStock] = useState<StockData[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [searchTerm, setSearchTerm] = useState('');
const [targetCoverageDays, setTargetCoverageDays] = useState(30);
@@ -135,6 +156,10 @@ const Cutting = () => {
const [correctionPage, setCorrectionPage] = useState(1);
const [currentPage, setCurrentPage] = useState(1);
const [itemsPerPage, setItemsPerPage] = useState(10);
const [isGeneratingOrders, setIsGeneratingOrders] = useState(false);
const [isOrderPreviewOpen, setIsOrderPreviewOpen] = useState(false);
const [activeCuttingView, setActiveCuttingView] = useState<CuttingView>('plan');
const [generationMessage, setGenerationMessage] = useState('');
const cutConfigSection = searchParams.get('config');
const targetCorrectionSku = (searchParams.get('sku') || '').trim();
@@ -169,13 +194,17 @@ const Cutting = () => {
const loadProducts = async () => {
setIsLoading(true);
const [productData, productionOrderData] = await Promise.all([
const [productData, productionOrderData, compositionData, stockData] = await Promise.all([
fetchProductAnalytics(dateRange),
fetchProductionOrders(allProductionOrdersRange)
fetchProductionOrders(allProductionOrdersRange),
fetchProductCompositions(),
fetchStock()
]);
if (isMounted) {
setProducts(productData);
setProductionOrders(productionOrderData.orders);
setCompositions(compositionData);
setMaterialStock(stockData);
setIsLoading(false);
}
};
@@ -197,6 +226,59 @@ const Cutting = () => {
[cuttingSettings, dateRange, openProductionByProductId, products, targetCoverageDays]
);
const materialReadinessByProductId = useMemo(() => {
const compositionsById = new Map<string, ProductComposition>();
compositions.forEach(composition => {
[composition.finishedTinyProductId, composition.finishedProductSku]
.map(normalizeMaterialKey)
.filter(Boolean)
.forEach(key => compositionsById.set(key, composition));
});
const stockIndexByKey = new Map<string, number>();
materialStock.forEach((stock, index) => {
[stock.produto_id, stock.nome].map(normalizeMaterialKey).filter(Boolean).forEach(key => stockIndexByKey.set(key, index));
});
const remainingStock = materialStock.map(stock => getPlanningStock(stock.saldo));
const readinessByProductId = new Map<string, MaterialReadiness>();
// Shared stock is allocated to the largest current requirement first. This
// prevents every SKU from claiming the same material balance independently.
[...cutPlan.rows].sort((a, b) => b.suggestedCutQuantity - a.suggestedCutQuantity || a.name.localeCompare(b.name, 'pt-BR')).forEach(row => {
const composition = compositionsById.get(normalizeMaterialKey(row.id));
if (!composition?.components.length) {
readinessByProductId.set(row.id, { status: 'missing', blockers: [] });
return;
}
const componentPlans = composition.components
.filter(component => !isServiceComponent(component.componentName))
.map(component => {
const stockKeys = [component.componentTinyId, component.componentSku, component.productId || '', component.componentName].map(normalizeMaterialKey).filter(Boolean);
const stockIndex = stockKeys.map(key => stockIndexByKey.get(key)).find(index => index !== undefined);
const available = stockIndex === undefined ? null : remainingStock[stockIndex];
const required = row.suggestedCutQuantity * component.quantityPerUnit;
return { component, stockIndex, available, required };
});
if (!componentPlans.length) {
readinessByProductId.set(row.id, { status: 'missing', blockers: ['Sem material produtivo na composição'] });
return;
}
const blockers = componentPlans.flatMap(({ component, available, required }) => {
if (!Number.isFinite(component.quantityPerUnit) || component.quantityPerUnit <= 0) return [`${component.componentName} (quantidade inválida)`];
return available === null || available < required
? [`${component.componentName}${available === null ? ' (sem estoque vinculado)' : ` (${formatNumber(available)} / ${formatNumber(required)} ${component.unit || ''})`}`]
: [];
});
if (blockers.length) {
readinessByProductId.set(row.id, { status: 'blocked', blockers });
return;
}
componentPlans.forEach(({ stockIndex, required }) => {
if (stockIndex !== undefined) remainingStock[stockIndex] -= required;
});
readinessByProductId.set(row.id, { status: row.issues.length === 0 ? 'ready' : 'material_ready', blockers: [] });
});
return readinessByProductId;
}, [compositions, cutPlan.rows, materialStock]);
const issueSummaries = useMemo(() => {
const counts = new Map<CutIssue, number>();
cutPlan.needRows.forEach(row => {
@@ -241,6 +323,32 @@ const Cutting = () => {
});
}, [cutFilter, cutPlan.rows, familyFilter, searchTerm, sortBy]);
const rowsAvailableForOrder = useMemo(() => (
filteredRows.filter(row => row.suggestedCutQuantity > 0 && materialReadinessByProductId.get(row.id)?.status === 'ready')
), [filteredRows, materialReadinessByProductId]);
const cutExecutionSummary = useMemo(() => {
const rowsWithNeed = cutPlan.needRows.filter(row => row.suggestedCutQuantity > 0);
const readyRows = rowsWithNeed.filter(row => materialReadinessByProductId.get(row.id)?.status === 'ready');
const blockedByMaterial = rowsWithNeed.filter(row => {
const status = materialReadinessByProductId.get(row.id)?.status;
return status === 'blocked' || status === 'missing';
});
const blockedByData = rowsWithNeed.filter(row => materialReadinessByProductId.get(row.id)?.status === 'material_ready');
return {
rawUnits: rowsWithNeed.reduce((total, row) => total + row.suggestedCutQuantity, 0),
readyUnits: readyRows.reduce((total, row) => total + row.suggestedCutQuantity, 0),
readyRows: readyRows.length,
blockedRows: blockedByMaterial.length + blockedByData.length,
blockedByMaterial: blockedByMaterial.length,
blockedByData: blockedByData.length,
};
}, [cutPlan.needRows, materialReadinessByProductId]);
const orderPreviewTotalUnits = useMemo(() => (
rowsAvailableForOrder.reduce((total, row) => total + row.suggestedCutQuantity, 0)
), [rowsAvailableForOrder]);
const totalPages = Math.ceil(filteredRows.length / itemsPerPage);
const safeCurrentPage = Math.min(currentPage, totalPages || 1);
const startIndex = (safeCurrentPage - 1) * itemsPerPage;
@@ -289,12 +397,16 @@ const Cutting = () => {
}, [correctionItemsPerPage, correctionRows, cutConfigSection, targetCorrectionSku]);
useEffect(() => {
if (!isSettingsOpen) return undefined;
if (!isSettingsOpen && !isOrderPreviewOpen) return undefined;
const closeOnEscape = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
if (isOrderPreviewOpen) {
setIsOrderPreviewOpen(false);
} else {
setIsSettingsOpen(false);
}
}
};
const previousOverflow = document.body.style.overflow;
@@ -304,7 +416,7 @@ const Cutting = () => {
document.body.style.overflow = previousOverflow;
document.removeEventListener('keydown', closeOnEscape);
};
}, [isSettingsOpen]);
}, [isOrderPreviewOpen, isSettingsOpen]);
const updateFamilyYield = (familyKey: CutFamilyKey, value: string) => {
const parsedValue = Number(value);
@@ -396,10 +508,77 @@ const Cutting = () => {
'Rendimento un/rolo': row.family.unitsPerRoll || '',
'Rolos estimados': row.estimatedRolls || '',
'Cobertura': row.daysOfCover === null ? '' : row.daysOfCover.toFixed(1).replace('.', ','),
'Pronto para cortar': materialReadinessByProductId.get(row.id)?.status === 'ready' ? 'Sim' : materialReadinessByProductId.get(row.id)?.status === 'blocked' ? 'Não - falta material' : 'Sem composição',
'Materiais bloqueando': materialReadinessByProductId.get(row.id)?.blockers.join(' | ') || '',
'Dados pendentes': row.issues.map(issue => issueLabels[issue]).join(' | ')
})), `plano_corte_${new Date().toISOString().split('T')[0]}.csv`);
};
const openProductionOrderPreview = () => {
if (!rowsAvailableForOrder.length) {
setGenerationMessage('Não há SKU com necessidade e materiais disponíveis no filtro atual.');
return;
}
setGenerationMessage('');
setIsOrderPreviewOpen(true);
};
const generateProductionOrders = async () => {
if (!rowsAvailableForOrder.length) {
setGenerationMessage('Não há SKU com necessidade e materiais disponíveis no filtro atual.');
return;
}
setIsGeneratingOrders(true);
setGenerationMessage('');
const issueDate = formatDateKey(new Date());
const orderReference = `Plano de corte ${formatDateKey(dateRange.start)} a ${formatDateKey(dateRange.end)} · ${targetCoverageDays} dias`;
try {
const result = await createProductionOrders(rowsAvailableForOrder.map(row => ({
status: 'open',
orderReference,
issueDate,
productSku: row.id,
productDescription: row.name,
quantity: row.suggestedCutQuantity,
unit: 'UN',
integrationStatus: 'Local',
markers: [
{ label: row.family.materialLabel, color: '#38bdf8' },
...(row.color ? [{ label: row.color, color: '#52DFA0' }] : []),
{ label: 'Material confirmado', color: '#52DFA0' },
...(row.issues.length ? [{ label: 'Dados pendentes', color: '#f59e0b' }] : [])
],
metadata: {
source: 'cut_plan',
familyKey: row.family.key,
familyLabel: row.family.label,
materialLabel: row.family.materialLabel,
color: row.color,
size: row.size,
targetCoverageDays,
projectedDemand: row.projectedDemand,
stock: row.stock,
openProductionQuantity: row.openProductionQuantity,
availableQuantity: row.availableQuantity,
issues: row.issues
}
})));
const refreshedOrders = await fetchProductionOrders(allProductionOrdersRange, undefined, { force: true });
setProductionOrders(refreshedOrders.orders);
setGenerationMessage(`${result.created.length} OPs criadas${result.skipped.length ? ` · ${result.skipped.length} já existiam abertas` : ''}.`);
setIsOrderPreviewOpen(false);
} catch (error) {
setGenerationMessage(error instanceof Error ? error.message : 'Não foi possível gerar as ordens de produção.');
} finally {
setIsGeneratingOrders(false);
}
};
const renderIssueBadge = (row: CutPlanSkuRow) => {
if (!row.issues.length) {
return <span className="text-xs font-bold text-emerald-300">OK</span>;
@@ -420,6 +599,14 @@ const Cutting = () => {
);
};
const renderMaterialReadiness = (row: CutPlanSkuRow) => {
const readiness = materialReadinessByProductId.get(row.id);
if (readiness?.status === 'ready') return <span className="text-xs font-bold text-emerald-300">Pode cortar</span>;
if (readiness?.status === 'material_ready') return <span className="text-xs font-bold text-sky-300">Material OK · revisar dados</span>;
if (readiness?.status === 'missing') return <span className="text-xs font-bold text-amber-300">Sem composição</span>;
return <span className="inline-flex max-w-full rounded-full border border-red-400/30 bg-red-400/10 px-2 py-1 text-xs font-bold text-red-300" title={readiness?.blockers.join(' · ')}><span className="truncate">Falta: {readiness?.blockers[0] || 'material'}</span></span>;
};
return (
<div className="space-y-6">
<div className="grid grid-cols-1 gap-4 2xl:grid-cols-[minmax(520px,1fr)_auto] 2xl:items-start">
@@ -444,6 +631,17 @@ const Cutting = () => {
}}
/>
<button
type="button"
onClick={openProductionOrderPreview}
disabled={isGeneratingOrders || !rowsAvailableForOrder.length}
className="flex items-center justify-center gap-2 rounded-xl border border-brand-primary/30 bg-brand-primary/15 px-4 py-2.5 text-sm font-medium text-brand-primary shadow-sm transition-colors hover:border-brand-primary disabled:cursor-not-allowed disabled:opacity-50 cursor-pointer"
title="Revisar ordens locais antes de gerar"
>
<ClipboardList size={16} />
<span className="hidden sm:inline">{isGeneratingOrders ? 'Gerando' : 'Prévia OPs'}</span>
</button>
<button
type="button"
onClick={exportRows}
@@ -466,8 +664,136 @@ const Cutting = () => {
</div>
</div>
{generationMessage && (
<div className="rounded-xl border border-dark-border bg-dark-card px-4 py-3 text-sm font-bold text-dark-muted">
{generationMessage}
</div>
)}
<RefreshStatus isRefreshing={isRefreshing} />
{isOrderPreviewOpen && (
<div
className="fixed inset-0 z-50 flex h-dvh items-center justify-center overflow-hidden bg-zinc-950/80 p-4 backdrop-blur-md dark:bg-black/75"
role="dialog"
aria-modal="true"
aria-label="Prévia de ordens de produção"
onClick={() => {
if (!isGeneratingOrders) setIsOrderPreviewOpen(false);
}}
>
<div
className="flex max-h-[90dvh] w-full max-w-6xl flex-col overflow-hidden rounded-2xl border border-dark-border bg-dark-card shadow-2xl"
onClick={(event) => event.stopPropagation()}
>
<div className="flex flex-col gap-4 border-b border-dark-border p-5 md:flex-row md:items-start md:justify-between">
<div>
<h2 className="text-lg font-bold text-dark-text">Prévia das OPs</h2>
<p className="mt-1 text-sm font-semibold text-dark-muted">
Serão criadas ordens locais para as necessidades do filtro atual.
</p>
</div>
<button
type="button"
onClick={() => setIsOrderPreviewOpen(false)}
disabled={isGeneratingOrders}
className="inline-flex h-9 w-9 items-center justify-center rounded-xl border border-dark-border bg-dark-input text-dark-muted transition-colors hover:border-brand-primary hover:text-dark-text disabled:cursor-not-allowed disabled:opacity-50 cursor-pointer"
title="Fechar"
aria-label="Fechar prévia"
>
<X className="h-4 w-4" />
</button>
</div>
<div className="grid grid-cols-1 gap-3 border-b border-dark-border p-4 md:grid-cols-3">
<div className="rounded-xl border border-dark-border bg-dark-input/60 p-3">
<p className="text-[10px] font-bold uppercase tracking-widest text-dark-muted">OPs previstas</p>
<p className="mt-2 text-2xl font-bold text-dark-text">{formatNumber(rowsAvailableForOrder.length)}</p>
</div>
<div className="rounded-xl border border-dark-border bg-dark-input/60 p-3">
<p className="text-[10px] font-bold uppercase tracking-widest text-dark-muted">Unidades totais</p>
<p className="mt-2 text-2xl font-bold text-red-300">{formatNumber(orderPreviewTotalUnits)} un.</p>
</div>
<div className="rounded-xl border border-dark-border bg-dark-input/60 p-3">
<p className="text-[10px] font-bold uppercase tracking-widest text-dark-muted">Cobertura alvo</p>
<p className="mt-2 text-2xl font-bold text-brand-primary">{formatNumber(targetCoverageDays)} dias</p>
</div>
</div>
<div className="overflow-auto">
<table className="w-full min-w-[980px] text-left text-sm">
<thead className="border-b border-dark-border bg-dark-header text-dark-muted">
<tr>
<th className="px-4 py-3 text-[10px] font-bold uppercase tracking-wider">SKU</th>
<th className="px-4 py-3 text-[10px] font-bold uppercase tracking-wider">Produto</th>
<th className="px-4 py-3 text-[10px] font-bold uppercase tracking-wider">Família</th>
<th className="px-4 py-3 text-[10px] font-bold uppercase tracking-wider">Variação</th>
<th className="px-4 py-3 text-[10px] font-bold uppercase tracking-wider">Necessidade</th>
<th className="px-4 py-3 text-[10px] font-bold uppercase tracking-wider">Dados</th>
</tr>
</thead>
<tbody className="divide-y divide-dark-border">
{rowsAvailableForOrder.map(row => (
<tr key={row.id} className="hover:bg-dark-input/50">
<td className="px-4 py-3 font-mono text-[11px] text-dark-muted">#{row.id}</td>
<td className="max-w-0 px-4 py-3">
<div className="truncate font-bold text-dark-text" title={row.name}>{row.name}</div>
<div className="mt-1 text-[10px] font-semibold text-dark-muted">
Média: {formatNumber(row.dailySales, 2)} un./dia · Estoque: {formatNumber(row.stock)} un.
</div>
</td>
<td className="px-4 py-3">
<span className={`inline-flex whitespace-nowrap rounded-full border px-2.5 py-1 text-xs font-bold ${familyStyles[row.family.key]}`}>
{row.family.materialLabel}
</span>
</td>
<td className="px-4 py-3">
<div className="flex min-w-0 items-center gap-1.5">
<ProductColorBadge label={row.color} className="max-w-[110px] px-2" />
<span className="inline-flex items-center gap-1 rounded-full border border-emerald-400/25 bg-emerald-400/10 px-2 py-1 text-xs font-bold text-emerald-300">
<Ruler className="h-3 w-3" />
{row.size || '-'}
</span>
</div>
</td>
<td className="px-4 py-3 font-bold whitespace-nowrap text-red-300">
{formatNumber(row.suggestedCutQuantity)} un.
</td>
<td className="px-4 py-3">{renderIssueBadge(row)}</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="flex flex-col gap-3 border-t border-dark-border p-4 sm:flex-row sm:items-center sm:justify-between">
<p className="text-xs font-semibold text-dark-muted">
OPs abertas existentes para o mesmo plano e SKU serão ignoradas.
</p>
<div className="flex flex-col gap-2 sm:flex-row sm:justify-end">
<button
type="button"
onClick={() => setIsOrderPreviewOpen(false)}
disabled={isGeneratingOrders}
className="inline-flex items-center justify-center rounded-xl border border-dark-border bg-dark-input px-4 py-2 text-sm font-bold text-dark-text transition-colors hover:border-brand-primary disabled:cursor-not-allowed disabled:opacity-50 cursor-pointer"
>
Cancelar
</button>
<button
type="button"
onClick={() => void generateProductionOrders()}
disabled={isGeneratingOrders}
className="inline-flex items-center justify-center gap-2 rounded-xl border border-brand-primary/30 bg-brand-primary/15 px-4 py-2 text-sm font-bold text-brand-primary transition-colors hover:border-brand-primary disabled:cursor-not-allowed disabled:opacity-50 cursor-pointer"
>
<ClipboardList className="h-4 w-4" />
{isGeneratingOrders ? 'Gerando OPs' : 'Confirmar geração'}
</button>
</div>
</div>
</div>
</div>
)}
{isSettingsOpen && (
<div
className="fixed inset-0 z-50 flex h-dvh justify-end overflow-hidden bg-zinc-950/80 backdrop-blur-md dark:bg-black/75"
@@ -699,8 +1025,8 @@ const Cutting = () => {
<div className="rounded-2xl border border-dark-border bg-dark-card p-5 shadow-sm">
<div className="flex items-start justify-between gap-4">
<div>
<p className="text-xs font-bold uppercase tracking-widest text-dark-muted">Unidades a cortar</p>
<p className="mt-2 text-3xl font-bold text-red-300">{formatNumber(cutPlan.summary.suggestedCutQuantity)}</p>
<p className="text-xs font-bold uppercase tracking-widest text-dark-muted">Necessidade bruta</p>
<p className="mt-2 text-3xl font-bold text-red-300">{formatNumber(cutExecutionSummary.rawUnits)}</p>
<p className="mt-1 text-xs font-semibold text-dark-muted">{formatNumber(cutPlan.summary.skuCount)} SKUs com necessidade</p>
</div>
<div className="rounded-xl border border-red-400/25 bg-red-400/10 p-3 text-red-300">
@@ -712,11 +1038,9 @@ const Cutting = () => {
<div className="rounded-2xl border border-dark-border bg-dark-card p-5 shadow-sm">
<div className="flex items-start justify-between gap-4">
<div>
<p className="text-xs font-bold uppercase tracking-widest text-dark-muted">Famílias</p>
<p className="mt-2 text-3xl font-bold text-dark-text">{formatNumber(cutPlan.summary.familiesWithNeed)}</p>
<p className="mt-1 text-xs font-semibold text-dark-muted">
{cutPlan.summary.estimatedRolls === null ? 'Rolos pendentes de rendimento' : `${formatNumber(cutPlan.summary.estimatedRolls)} rolos estimados`}
</p>
<p className="text-xs font-bold uppercase tracking-widest text-dark-muted">Pronto para corte</p>
<p className="mt-2 text-3xl font-bold text-emerald-300">{formatNumber(cutExecutionSummary.readyUnits)}</p>
<p className="mt-1 text-xs font-semibold text-dark-muted">{formatNumber(cutExecutionSummary.readyRows)} SKUs liberados para gerar OP</p>
</div>
<div className="rounded-xl border border-brand-primary/25 bg-brand-primary/10 p-3 text-brand-primary">
<Layers3 className="h-5 w-5" />
@@ -727,14 +1051,12 @@ const Cutting = () => {
<div className="rounded-2xl border border-dark-border bg-dark-card p-5 shadow-sm">
<div className="flex items-start justify-between gap-4">
<div>
<p className="text-xs font-bold uppercase tracking-widest text-dark-muted">Cores / tamanhos</p>
<p className="mt-2 text-3xl font-bold text-sky-300">
{formatNumber(cutPlan.summary.colorsWithNeed)} / {formatNumber(cutPlan.summary.sizesWithNeed)}
</p>
<p className="mt-1 text-xs font-semibold text-dark-muted">Com corte sugerido</p>
<p className="text-xs font-bold uppercase tracking-widest text-dark-muted">Bloqueios</p>
<p className="mt-2 text-3xl font-bold text-amber-300">{formatNumber(cutExecutionSummary.blockedRows)}</p>
<p className="mt-1 text-xs font-semibold text-dark-muted">{formatNumber(cutExecutionSummary.blockedByMaterial)} material · {formatNumber(cutExecutionSummary.blockedByData)} dados</p>
</div>
<div className="rounded-xl border border-sky-400/25 bg-sky-400/10 p-3 text-sky-300">
<Palette className="h-5 w-5" />
<div className="rounded-xl border border-amber-400/25 bg-amber-400/10 p-3 text-amber-300">
<AlertTriangle className="h-5 w-5" />
</div>
</div>
</div>
@@ -742,18 +1064,80 @@ const Cutting = () => {
<div className="rounded-2xl border border-dark-border bg-dark-card p-5 shadow-sm">
<div className="flex items-start justify-between gap-4">
<div>
<p className="text-xs font-bold uppercase tracking-widest text-dark-muted">OP aberta</p>
<p className="mt-2 text-3xl font-bold text-amber-300">{formatNumber(cutPlan.summary.openProductionQuantity)}</p>
<p className="mt-1 text-xs font-semibold text-dark-muted">Unidades abatidas quando match</p>
<p className="text-xs font-bold uppercase tracking-widest text-dark-muted">Famílias</p>
<p className="mt-2 text-3xl font-bold text-brand-primary">{formatNumber(cutPlan.summary.familiesWithNeed)}</p>
<p className="mt-1 text-xs font-semibold text-dark-muted">{cutPlan.summary.estimatedRolls === null ? 'Rendimento ainda não configurado' : `${formatNumber(cutPlan.summary.estimatedRolls)} rolos estimados`}</p>
</div>
<div className="rounded-xl border border-amber-400/25 bg-amber-400/10 p-3 text-amber-300">
<ClipboardList className="h-5 w-5" />
<div className="rounded-xl border border-brand-primary/25 bg-brand-primary/10 p-3 text-brand-primary">
<Layers3 className="h-5 w-5" />
</div>
</div>
</div>
</div>
{!!issueSummaries.length && (
{cutExecutionSummary.rawUnits > 0 && cutExecutionSummary.readyUnits === 0 && (
<div className="flex flex-col gap-3 rounded-2xl border border-amber-400/30 bg-amber-400/10 p-4 md:flex-row md:items-center md:justify-between">
<div>
<p className="text-sm font-bold text-amber-300"> demanda, mas nenhum SKU está liberado para corte.</p>
<p className="mt-1 text-xs font-semibold text-dark-muted">Corrija materiais bloqueados e rendimentos antes de gerar ordens de produção.</p>
</div>
<button type="button" onClick={() => setActiveCuttingView('issues')} className="h-9 rounded-lg border border-amber-400/30 bg-dark-card px-3 text-xs font-bold text-amber-300 hover:border-amber-400 cursor-pointer">Ver pendências</button>
</div>
)}
<div className="rounded-2xl border border-dark-border bg-dark-card p-2 shadow-sm">
<div className="grid grid-cols-1 gap-2 md:grid-cols-3">
<button
type="button"
onClick={() => setActiveCuttingView('plan')}
aria-pressed={activeCuttingView === 'plan'}
className={`flex items-center justify-between gap-3 rounded-xl border px-4 py-3 text-left transition-colors cursor-pointer ${activeCuttingView === 'plan' ? 'border-brand-primary/40 bg-brand-primary/15 text-brand-primary' : 'border-transparent text-dark-muted hover:bg-dark-input hover:text-dark-text'}`}
>
<span className="flex items-center gap-3">
<Scissors className="h-4 w-4" />
<span>
<span className="block text-sm font-bold">Necessidades</span>
<span className="block text-xs font-semibold opacity-80">Demanda por SKU</span>
</span>
</span>
<span className="text-sm font-bold">{formatNumber(cutPlan.summary.skuCount)}</span>
</button>
<button
type="button"
onClick={() => setActiveCuttingView('families')}
aria-pressed={activeCuttingView === 'families'}
className={`flex items-center justify-between gap-3 rounded-xl border px-4 py-3 text-left transition-colors cursor-pointer ${activeCuttingView === 'families' ? 'border-brand-primary/40 bg-brand-primary/15 text-brand-primary' : 'border-transparent text-dark-muted hover:bg-dark-input hover:text-dark-text'}`}
>
<span className="flex items-center gap-3">
<Layers3 className="h-4 w-4" />
<span>
<span className="block text-sm font-bold">Famílias</span>
<span className="block text-xs font-semibold opacity-80">Resumo de corte</span>
</span>
</span>
<span className="text-sm font-bold">{formatNumber(cutPlan.familySummaries.length)}</span>
</button>
<button
type="button"
onClick={() => setActiveCuttingView('issues')}
aria-pressed={activeCuttingView === 'issues'}
className={`flex items-center justify-between gap-3 rounded-xl border px-4 py-3 text-left transition-colors cursor-pointer ${activeCuttingView === 'issues' ? 'border-amber-400/40 bg-amber-400/10 text-amber-300' : 'border-transparent text-dark-muted hover:bg-dark-input hover:text-dark-text'}`}
>
<span className="flex items-center gap-3">
<AlertTriangle className="h-4 w-4" />
<span>
<span className="block text-sm font-bold">Pendências</span>
<span className="block text-xs font-semibold opacity-80">Dados a revisar</span>
</span>
</span>
<span className="text-sm font-bold">{formatNumber(cutPlan.summary.rowsWithIssues)}</span>
</button>
</div>
</div>
{activeCuttingView === 'issues' && (
<div className="rounded-2xl border border-dark-border bg-dark-card p-4 shadow-sm">
<div className="mb-3 flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div>
@@ -776,8 +1160,21 @@ const Cutting = () => {
<Settings2 className="h-4 w-4 text-brand-primary" />
Corrigir
</button>
<button
type="button"
onClick={() => {
setCutFilter('issues');
setCurrentPage(1);
setActiveCuttingView('plan');
}}
className="inline-flex items-center justify-center gap-2 rounded-xl border border-dark-border bg-dark-input px-3 py-2 text-xs font-bold text-dark-text transition-colors hover:border-brand-primary cursor-pointer"
>
<Search className="h-4 w-4 text-brand-primary" />
Ver SKUs
</button>
</div>
</div>
{issueSummaries.length ? (
<div className="grid grid-cols-1 gap-3 md:grid-cols-4">
{issueSummaries.map(summary => (
<div
@@ -792,10 +1189,16 @@ const Cutting = () => {
</div>
))}
</div>
) : (
<div className="rounded-xl border border-dark-border bg-dark-input/60 px-4 py-8 text-center">
<p className="text-sm font-bold text-dark-text">Nenhuma pendência no plano atual.</p>
<p className="mt-1 text-xs font-semibold text-dark-muted">Os SKUs com necessidade têm os dados mínimos para o cálculo.</p>
</div>
)}
</div>
)}
{!!cutPlan.familySummaries.length && (
{activeCuttingView === 'families' && (
<div className="rounded-2xl border border-dark-border bg-dark-card p-4 shadow-sm">
<div className="mb-3 flex items-center justify-between gap-3">
<div>
@@ -804,6 +1207,7 @@ const Cutting = () => {
</div>
<Scissors className="h-5 w-5 text-dark-muted" />
</div>
{cutPlan.familySummaries.length ? (
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 2xl:grid-cols-5">
{cutPlan.familySummaries.map(summary => (
<div
@@ -826,9 +1230,17 @@ const Cutting = () => {
</div>
))}
</div>
) : (
<div className="rounded-xl border border-dark-border bg-dark-input/60 px-4 py-8 text-center">
<p className="text-sm font-bold text-dark-text">Nenhuma família com necessidade no período.</p>
<p className="mt-1 text-xs font-semibold text-dark-muted">Ajuste o período ou a cobertura alvo para revisar outro cenário.</p>
</div>
)}
</div>
)}
{activeCuttingView === 'plan' && (
<>
<div className="grid grid-cols-1 gap-3 rounded-2xl border border-dark-border bg-dark-card p-4 shadow-sm xl:grid-cols-[1fr_170px_170px_170px_190px]">
<div className="relative">
<Search className="absolute left-3 top-1/2 h-5 w-5 -translate-y-1/2 text-zinc-400 dark:text-dark-muted" />
@@ -901,7 +1313,7 @@ const Cutting = () => {
) : (
<div className={`overflow-hidden rounded-2xl border border-zinc-200 bg-white shadow-sm dark:border-dark-border dark:bg-dark-card ${isRefreshing ? 'refreshing-content' : ''}`} aria-busy={isRefreshing}>
<div className="overflow-x-auto">
<table className="w-full min-w-[1180px] table-fixed text-left text-sm">
<table className="w-full min-w-[1450px] table-fixed text-left text-sm">
<colgroup>
<col className="w-[105px]" />
<col className="w-[320px]" />
@@ -911,8 +1323,9 @@ const Cutting = () => {
<col className="w-[125px]" />
<col className="w-[140px]" />
<col className="w-[80px]" />
<col className="w-[130px]" />
<col className="w-[110px]" />
<col className="w-[230px]" />
<col className="w-[160px]" />
<col className="w-[100px]" />
</colgroup>
<thead className="border-b border-zinc-100 bg-zinc-50 text-zinc-500 dark:border-dark-border dark:bg-dark-header dark:text-dark-muted">
<tr>
@@ -924,6 +1337,7 @@ const Cutting = () => {
<th className="px-4 py-4 text-[10px] font-bold uppercase tracking-wider">Estoque</th>
<th className="px-4 py-4 text-[10px] font-bold uppercase tracking-wider">Necessidade</th>
<th className="px-4 py-4 text-[10px] font-bold uppercase tracking-wider">Rolos</th>
<th className="px-4 py-4 text-[10px] font-bold uppercase tracking-wider">Materiais</th>
<th
className="px-4 py-4 text-[10px] font-bold uppercase tracking-wider"
title="Dados que ainda faltam para fechar o plano de corte do SKU."
@@ -972,7 +1386,8 @@ const Cutting = () => {
<td className="px-4 py-2.5 font-bold whitespace-nowrap text-zinc-900 dark:text-dark-text">
{row.estimatedRolls === null ? '-' : formatNumber(row.estimatedRolls)}
</td>
<td className="px-4 py-2.5">{renderIssueBadge(row)}</td>
<td className="px-4 py-2.5 align-middle">{renderMaterialReadiness(row)}</td>
<td className="px-4 py-2.5 align-middle">{renderIssueBadge(row)}</td>
<td className="px-4 py-2.5 text-right">
<div className="flex justify-end gap-2">
<Link
@@ -1025,6 +1440,8 @@ const Cutting = () => {
/>
</div>
)}
</>
)}
</div>
);
};

View File

@@ -1,29 +1,187 @@
import { useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Lock } from 'lucide-react';
import { login } from '../dataService';
import { getLoginConfig, login } from '../dataService';
declare global {
interface Window {
turnstile?: {
render: (
container: HTMLElement,
options: {
sitekey: string;
callback: (token: string) => void;
'expired-callback': () => void;
'error-callback': () => void;
theme: 'dark' | 'light' | 'auto';
}
) => string;
reset: (widgetId?: string) => void;
};
}
}
const turnstileScriptId = 'turnstile-api-script';
const securityConfigLoadMessage = 'Não foi possível carregar as configurações de segurança. Atualize a página e tente novamente.';
const securityUnavailableMessage = 'Não foi possível carregar a verificação de segurança. Atualize a página e tente novamente.';
const securityConfigurationMessage = 'Verificação de segurança indisponível. Entre em contato com o administrador.';
const securityRequiredMessage = 'Conclua a verificação de segurança para continuar.';
const securityFailedMessage = 'Não foi possível validar a verificação de segurança. Atualize a página e tente novamente.';
const turnstileSiteKeyPattern = /^[0-9]x[0-9A-Za-z_-]{20,}$/;
const Login = () => {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [captchaToken, setCaptchaToken] = useState('');
const [error, setError] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [isConfigLoading, setIsConfigLoading] = useState(true);
const [turnstileSiteKey, setTurnstileSiteKey] = useState('');
const [captchaRequired, setCaptchaRequired] = useState(false);
const [captchaReady, setCaptchaReady] = useState(true);
const captchaContainerRef = useRef<HTMLDivElement | null>(null);
const captchaWidgetIdRef = useRef<string | null>(null);
const navigate = useNavigate();
const hasValidSiteKey = turnstileSiteKeyPattern.test(turnstileSiteKey);
const captchaEnabled = Boolean(captchaRequired && hasValidSiteKey);
const securityMisconfigured = captchaRequired && !hasValidSiteKey;
useEffect(() => {
let isMounted = true;
const loadLoginConfig = async () => {
try {
const config = await getLoginConfig();
if (!isMounted) return;
setCaptchaRequired(config.captchaRequired);
setTurnstileSiteKey(config.turnstileSiteKey.trim());
setCaptchaReady(!config.captchaRequired);
if (config.captchaRequired && !config.captchaConfigured) {
setError(securityConfigurationMessage);
}
} catch {
if (!isMounted) return;
setCaptchaReady(false);
setError(securityConfigLoadMessage);
} finally {
if (isMounted) {
setIsConfigLoading(false);
}
}
};
void loadLoginConfig();
return () => {
isMounted = false;
};
}, []);
useEffect(() => {
if (!hasValidSiteKey || !captchaContainerRef.current || captchaWidgetIdRef.current) return;
const siteKey = turnstileSiteKey;
const renderCaptcha = () => {
if (!window.turnstile || !captchaContainerRef.current || captchaWidgetIdRef.current) return;
try {
captchaWidgetIdRef.current = window.turnstile.render(captchaContainerRef.current, {
sitekey: siteKey,
theme: 'dark',
callback: (token) => {
setCaptchaToken(token);
setCaptchaReady(true);
},
'expired-callback': () => {
setCaptchaToken('');
setCaptchaReady(true);
},
'error-callback': () => {
setCaptchaToken('');
setCaptchaReady(false);
setError(securityUnavailableMessage);
},
});
setCaptchaReady(true);
} catch {
setCaptchaToken('');
setCaptchaReady(false);
setError(securityConfigurationMessage);
}
};
if (window.turnstile) {
renderCaptcha();
return;
}
const existingScript = document.getElementById(turnstileScriptId);
if (existingScript) {
existingScript.addEventListener('load', renderCaptcha, { once: true });
return () => existingScript.removeEventListener('load', renderCaptcha);
}
const script = document.createElement('script');
script.id = turnstileScriptId;
script.src = 'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit';
script.async = true;
script.defer = true;
script.addEventListener('load', renderCaptcha, { once: true });
script.addEventListener('error', () => {
setCaptchaReady(false);
setError(securityUnavailableMessage);
}, { once: true });
document.head.appendChild(script);
return () => script.removeEventListener('load', renderCaptcha);
}, [hasValidSiteKey, turnstileSiteKey]);
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault();
if (securityMisconfigured) {
setError(securityConfigurationMessage);
return;
}
if (captchaEnabled && !captchaToken) {
setError(securityRequiredMessage);
return;
}
setIsLoading(true);
setError('');
try {
const success = await login(email, password);
if (success) {
const result = await login(email, password, captchaToken);
if (result === 'success') {
navigate('/graph');
} else {
} else if (result === 'captcha_failed') {
setError(captchaEnabled ? securityFailedMessage : securityConfigurationMessage);
if (captchaEnabled) {
setCaptchaToken('');
window.turnstile?.reset(captchaWidgetIdRef.current ?? undefined);
}
} else if (result === 'invalid_credentials') {
setError('E-mail ou senha incorretos.');
if (captchaEnabled) {
setCaptchaToken('');
window.turnstile?.reset(captchaWidgetIdRef.current ?? undefined);
}
} else {
setError('Erro ao conectar ao servidor.');
if (captchaEnabled) {
setCaptchaToken('');
window.turnstile?.reset(captchaWidgetIdRef.current ?? undefined);
}
}
} catch {
setError('Erro ao conectar ao servidor.');
if (captchaEnabled) {
setCaptchaToken('');
window.turnstile?.reset(captchaWidgetIdRef.current ?? undefined);
}
} finally {
setIsLoading(false);
}
@@ -64,14 +222,23 @@ const Login = () => {
/>
</div>
{error && <p className="text-red-500 text-sm font-medium">{error}</p>}
{(captchaEnabled || securityMisconfigured) && (
<div className="min-h-[70px] rounded-xl border border-dark-border bg-dark-input/60 p-3">
{captchaEnabled && <div ref={captchaContainerRef} className="flex justify-center" />}
{(securityMisconfigured || !captchaReady) && (
<p className="mt-2 text-center text-xs font-medium text-red-400">Verificação de segurança indisponível.</p>
)}
</div>
)}
{error && <p className="text-red-500 text-sm font-medium" role="alert">{error}</p>}
<button
type="submit"
disabled={isLoading}
disabled={isLoading || isConfigLoading || securityMisconfigured || !captchaReady || (captchaEnabled && !captchaToken)}
className="w-full bg-brand-primary hover:bg-opacity-90 hover:scale-[1.02] active:scale-[0.98] text-zinc-900 font-bold py-3 rounded-xl transition-all duration-200 disabled:opacity-50 disabled:hover:scale-100 disabled:active:scale-100 mt-4 cursor-pointer"
>
{isLoading ? 'Entrando...' : 'Entrar'}
{isLoading || isConfigLoading ? 'Entrando...' : 'Entrar'}
</button>
</form>
</div>

View File

@@ -218,9 +218,17 @@ const PlanningIssues = () => {
<div>
<h1 className="mb-2 text-2xl font-bold text-zinc-900 dark:text-dark-text">Dados Pendentes</h1>
<p className="font-medium text-zinc-500 dark:text-dark-muted">
Fila de SKUs com informações faltando para corte e planejamento.
Fila principal para revisar tipo, cor, tamanho, família e rendimento dos SKUs.
</p>
</div>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center 2xl:justify-end">
<Link
to="/registrations"
className="inline-flex h-10 items-center justify-center gap-2 rounded-xl border border-dark-border bg-dark-card px-4 text-sm font-bold text-dark-text transition-colors hover:border-brand-primary"
>
<PackageSearch className="h-4 w-4 text-brand-primary" />
Cadastros
</Link>
<DateRangePicker
dateRange={dateRange}
onChange={(range) => {
@@ -229,6 +237,7 @@ const PlanningIssues = () => {
}}
/>
</div>
</div>
<RefreshStatus isRefreshing={isRefreshing} />

View File

@@ -7,12 +7,14 @@ import DateRangePicker from '../components/DateRangePicker';
import SkuPlanningModal from '../components/SkuPlanningModal';
import ProductTypeBadge from '../components/ProductTypeBadge';
import RefreshStatus from '../components/RefreshStatus';
import type { CutProductOverride, CuttingSettings, DateRange, ProductDetailsAnalytics } from '../types';
import { fetchCuttingSettings, fetchProductDetailsAnalytics, saveCuttingSettings } from '../dataService';
import PaginationControls from '../components/PaginationControls';
import type { CutProductOverride, CuttingSettings, DateRange, ProductComposition, ProductDetailsAnalytics } from '../types';
import { fetchCuttingSettings, fetchProductComposition, fetchProductDetailsAnalytics, saveCuttingSettings } from '../dataService';
import { parseProductName } from '../productParsing';
import { formatColorLabel } from '../displayFormatters';
import { averageRecentValues, formatDateBucketLabel, formatDateBucketLongLabel, getAutoDateBucket, getMovingAverageWindow, getRangeDayCount, getDateBucketKey, type DateBucket } from '../chartUtils';
import { getProductTypeConfig, resolveProductType } from '../productClassification';
import { getPlanningStock } from '../planningStock';
const CHART_GRID_COLOR = 'var(--chart-grid)';
const CHART_AXIS_COLOR = 'var(--chart-axis)';
@@ -130,12 +132,25 @@ const ProductDetails = () => {
setDateRange: (range: DateRange) => void
}>();
const [details, setDetails] = useState<ProductDetailsAnalytics | null>(null);
const [composition, setComposition] = useState<ProductComposition | null>(null);
const [isCompositionLoading, setIsCompositionLoading] = useState(true);
const [isLoading, setIsLoading] = useState(true);
const [chartMetric, setChartMetric] = useState<ProductChartMetric>('quantity');
const [selectedProductBucket, setSelectedProductBucket] = useState<string | null>(null);
const [planningSettings, setPlanningSettings] = useState<CuttingSettings>({ familyYields: {}, productOverrides: {} });
const [isPlanningModalOpen, setIsPlanningModalOpen] = useState(false);
const [isSavingPlanning, setIsSavingPlanning] = useState(false);
const [variantCurrentPage, setVariantCurrentPage] = useState(1);
const [variantItemsPerPage, setVariantItemsPerPage] = useState(5);
useEffect(() => {
const scrollContainer = document.querySelector<HTMLElement>('.app-content');
if (scrollContainer) {
scrollContainer.scrollTo({ top: 0, behavior: 'auto' });
} else {
window.scrollTo({ top: 0, behavior: 'auto' });
}
}, [id]);
useEffect(() => {
let isMounted = true;
@@ -159,17 +174,26 @@ const ProductDetails = () => {
if (!id) {
if (isMounted) {
setDetails(null);
setComposition(null);
setIsLoading(false);
setIsCompositionLoading(false);
}
return;
}
setIsLoading(true);
const productDetails = await fetchProductDetailsAnalytics(id, dateRange);
setIsCompositionLoading(true);
const [productDetails, productComposition] = await Promise.all([
fetchProductDetailsAnalytics(id, dateRange),
fetchProductComposition(id)
]);
if (isMounted) {
setDetails(productDetails);
setComposition(productComposition);
setVariantCurrentPage(1);
setIsLoading(false);
setIsCompositionLoading(false);
}
};
@@ -229,7 +253,7 @@ const ProductDetails = () => {
const dateBucket = isHourlyChart ? 'day' : getAutoDateBucket(dateRange);
const periodDays = getRangeDayCount(dateRange);
const dailyAverageSold = totalSold / periodDays;
const projectedStockDays = dailyAverageSold > 0 ? productInfo.stock / dailyAverageSold : null;
const projectedStockDays = dailyAverageSold > 0 ? getPlanningStock(productInfo.stock) / dailyAverageSold : null;
const stockActionLabel = projectedStockDays === null
? 'Sem venda no período'
: projectedStockDays <= 7
@@ -320,7 +344,10 @@ const ProductDetails = () => {
: formatDateBucketLongLabel(selectedProductPoint.date, dateBucket)
: '';
const maxVariantQuantity = Math.max(...variantBreakdown.map(variant => variant.quantitySold), 0);
const variantTotalPages = Math.ceil(variantBreakdown.length / variantItemsPerPage);
const safeVariantCurrentPage = Math.min(variantCurrentPage, variantTotalPages || 1);
const variantStartIndex = (safeVariantCurrentPage - 1) * variantItemsPerPage;
const paginatedVariantBreakdown = variantBreakdown.slice(variantStartIndex, variantStartIndex + variantItemsPerPage);
return (
<div className="space-y-6">
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
@@ -529,6 +556,57 @@ const ProductDetails = () => {
)}
</div>
<section className="bg-white dark:bg-dark-card border border-zinc-200 dark:border-dark-border rounded-2xl p-6 shadow-sm">
<div className="mb-5">
<h3 className="text-lg font-bold text-zinc-900 dark:text-dark-text">Composição</h3>
{composition && (
<p className="mt-1 text-sm font-medium text-zinc-500 dark:text-dark-muted">
Para produzir 1 UN de {composition.finishedProductSku || productInfo.id}
</p>
)}
</div>
{isCompositionLoading ? (
<div className="flex h-24 items-center text-sm font-semibold text-zinc-500 dark:text-dark-muted">Carregando composição</div>
) : !composition ? (
<div className="flex h-24 items-center justify-center rounded-xl border border-dashed border-dark-border bg-dark-input/30 text-sm font-semibold text-zinc-500 dark:text-dark-muted">
Nenhuma composição sincronizada para este produto.
</div>
) : composition.components.length === 0 ? (
<div className="flex h-24 items-center justify-center rounded-xl border border-dashed border-dark-border bg-dark-input/30 text-sm font-semibold text-zinc-500 dark:text-dark-muted">
Esta composição não possui insumos.
</div>
) : (
<div className="overflow-x-auto rounded-xl border border-dark-border">
<table className="w-full min-w-[640px] text-left text-sm">
<thead className="bg-dark-input/60 text-[10px] font-bold uppercase tracking-widest text-dark-muted">
<tr>
<th className="px-4 py-3">Produto / insumo</th>
<th className="px-4 py-3">SKU</th>
<th className="px-4 py-3 text-right">Quantidade por unidade</th>
<th className="px-4 py-3">Unidade</th>
</tr>
</thead>
<tbody className="divide-y divide-dark-border">
{composition.components.map(component => (
<tr key={component.id} className="text-dark-text">
<td className="px-4 py-3 font-semibold">
{component.productId ? (
<Link to={`/products/${component.productId}`} className="text-brand-primary hover:underline">
{component.componentName}
</Link>
) : component.componentName}
</td>
<td className="px-4 py-3 font-mono text-xs text-dark-muted">{component.componentSku || '—'}</td>
<td className="px-4 py-3 text-right font-semibold">{formatNumber(component.quantityPerUnit)}</td>
<td className="px-4 py-3 text-dark-muted">{component.unit || '—'}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
<div className="bg-white dark:bg-dark-card border border-zinc-200 dark:border-dark-border rounded-2xl p-6 shadow-sm">
<div className="mb-5 flex flex-col gap-2 md:flex-row md:items-end md:justify-between">
<div>
@@ -545,15 +623,21 @@ const ProductDetails = () => {
</div>
) : (
<div className="space-y-3">
{variantBreakdown.map(variant => {
{paginatedVariantBreakdown.map(variant => {
const width = maxVariantQuantity ? Math.max(4, (variant.quantitySold / maxVariantQuantity) * 100) : 0;
const metadata = parseProductName(variant.name);
return (
<div key={variant.id} className="rounded-xl border border-dark-border bg-dark-input/45 p-4">
<div key={variant.id} className={`rounded-xl border p-4 ${
variant.id === productInfo.id
? 'border-brand-primary/50 bg-brand-primary/10 shadow-sm shadow-brand-primary/10'
: 'border-dark-border bg-dark-input/45'
}`}>
<div className="flex flex-col gap-2 md:flex-row md:items-start md:justify-between">
<div className="min-w-0">
<div className="truncate text-sm font-bold text-dark-text">{variant.name}</div>
<Link to={`/products/${variant.id}`} className="block truncate text-sm font-bold text-dark-text transition-colors hover:text-brand-primary hover:underline" title={`Ver produto ${variant.name}`}>
{variant.name}
</Link>
<div className="mt-1 flex flex-wrap items-center gap-2 text-[11px] font-medium text-dark-muted">
<span>#{variant.id}</span>
{metadata.color && (
@@ -594,6 +678,25 @@ const ProductDetails = () => {
})}
</div>
)}
{variantBreakdown.length > variantItemsPerPage && (
<PaginationControls
totalItems={variantBreakdown.length}
currentPage={safeVariantCurrentPage}
totalPages={variantTotalPages}
pageSize={variantItemsPerPage}
pageSizeOptions={[5, 10, 20]}
itemLabel="variações"
pageSizeLabel="por página"
startIndex={variantStartIndex}
endIndex={Math.min(variantStartIndex + variantItemsPerPage, variantBreakdown.length)}
onPageChange={setVariantCurrentPage}
onPageSizeChange={(pageSize) => {
setVariantItemsPerPage(pageSize);
setVariantCurrentPage(1);
}}
className="mt-5 border-t border-dark-border pt-4"
/>
)}
</div>
</div>

View File

@@ -14,6 +14,7 @@ import { decodeProductGroupKey, normalizeProductText, parseProductName } from '.
import { formatColorLabel } from '../displayFormatters';
import { getProductColor } from '../productColors';
import { getDominantProductType, getProductTypeConfig, resolveProductType, type ProductTypeKey } from '../productClassification';
import { getPlanningStock } from '../planningStock';
import type { CuttingSettings, DateRange, ProductAnalyticsItem, ProductionOrderItem } from '../types';
type VariantRow = ProductAnalyticsItem & {
@@ -263,7 +264,7 @@ const ProductGroupDetails = () => {
const dailySales = product.quantitySold / rangeDays;
const projectedDemand = dailySales * REPLENISHMENT_TARGET_DAYS;
const openProductionQuantity = openProductionByProductId[product.id] || 0;
const availableQuantity = product.stock + openProductionQuantity;
const availableQuantity = getPlanningStock(product.stock) + openProductionQuantity;
const suggestedReplenishment = Math.max(0, Math.ceil(projectedDemand - availableQuantity));
return {

View File

@@ -1,10 +1,10 @@
import { type FormEvent, useCallback, useEffect, useMemo, useState } from 'react';
import { Fragment, type FormEvent, useCallback, useEffect, useMemo, useState } from 'react';
import { Link, useOutletContext } from 'react-router-dom';
import { ArrowLeft, CalendarDays, CheckCircle2, ClipboardList, Clock3, Download, PackageCheck, Search } from 'lucide-react';
import DateRangePicker from '../components/DateRangePicker';
import PaginationControls from '../components/PaginationControls';
import RefreshStatus from '../components/RefreshStatus';
import { consumeSupplyLotForProduction, exportToCSV, fetchProductionOrders, fetchSupplySummary } from '../dataService';
import { consumeSupplyLotForProduction, exportToCSV, fetchProductionOrders, fetchSupplySummary, updateProductionOrderStatus } from '../dataService';
import type { DateRange, ProductionOrderItem, ProductionOrderStatus, ProductionOrderSummary, SupplyLot } from '../types';
type ProductionOrderStatusTab = 'all' | 'open' | 'in_progress' | 'finished' | 'canceled';
@@ -22,6 +22,10 @@ const statusTabs: Array<{ key: ProductionOrderStatusTab; label: string; dotClass
{ key: 'canceled', label: 'Cancelada', dotClass: 'bg-zinc-500' }
];
const editableStatusOptions: Array<{ value: ProductionOrderStatusTab; label: string }> = statusTabs
.filter(tab => tab.key !== 'all')
.map(tab => ({ value: tab.key, label: tab.label }));
const statusStyles: Record<string, { label: string; className: string; dotClass: string }> = {
open: {
label: 'Em aberto',
@@ -59,6 +63,16 @@ const formatQuantity = (value: number) => (
}).format(value)
);
const formatOptionalQuantity = (value: number | null) => (
value === null ? '-' : formatQuantity(value)
);
const hasProductionOrderDetails = (order: ProductionOrderItem) => (
order.components.length > 0 ||
order.steps.length > 0 ||
Boolean(order.notes || order.supplier || order.lotCode || order.rollQuantity || order.fabricKg || order.ribKg || order.yieldPiecesPerKg)
);
const getStatusStyle = (status: ProductionOrderStatus, fallbackLabel: string) => (
statusStyles[String(status)] || {
label: fallbackLabel || 'Em aberto',
@@ -121,6 +135,8 @@ const ProductionOrders = () => {
const [isLoading, setIsLoading] = useState(true);
const [isSupplyBusy, setIsSupplyBusy] = useState(false);
const [supplyMessage, setSupplyMessage] = useState('');
const [busyStatusOrderId, setBusyStatusOrderId] = useState<number | null>(null);
const [statusMessage, setStatusMessage] = useState('');
const [currentPage, setCurrentPage] = useState(1);
const [itemsPerPage, setItemsPerPage] = useState(20);
const [exitForm, setExitForm] = useState({
@@ -185,11 +201,29 @@ const ProductionOrders = () => {
const progressCount = summary.counts.in_progress || 0;
const finishedCount = summary.counts.finished || 0;
const totalQuantity = summary.orders.reduce((total, order) => total + order.quantity, 0);
const activeOrders = summary.orders.filter(order => order.status !== 'finished' && order.status !== 'canceled');
const handleManualRefresh = () => {
void loadProductionOrders({ force: true });
};
const handleStatusChange = async (order: ProductionOrderItem, status: ProductionOrderStatusTab) => {
if (status === 'all' || status === order.status) return;
setBusyStatusOrderId(order.id);
setStatusMessage('');
try {
await updateProductionOrderStatus(order.id, status);
const nextSummary = await fetchProductionOrders(dateRange, { search: searchTerm }, { force: true });
setSummary(nextSummary);
setStatusMessage(`OP ${order.number || `#${order.id}`} atualizada para ${statusStyles[status]?.label || status}.`);
} catch (error) {
setStatusMessage(error instanceof Error ? error.message : 'Não foi possível atualizar o status da OP.');
} finally {
setBusyStatusOrderId(null);
}
};
const refreshSupplyLots = async () => {
const supplySummary = await fetchSupplySummary();
setSupplyLots(supplySummary.lots);
@@ -248,7 +282,7 @@ const ProductionOrders = () => {
Suprimentos
</Link>
<h1 className="text-2xl font-bold text-dark-text">Ordens de Produção</h1>
<p className="mt-2 text-dark-muted font-medium">Acompanhe as ordens de produção sincronizadas do Tiny.</p>
<p className="mt-2 text-dark-muted font-medium">Acompanhe as ordens locais geradas pelo plano de corte.</p>
</div>
<DateRangePicker
dateRange={dateRange}
@@ -319,6 +353,7 @@ const ProductionOrders = () => {
</div>
</div>
{activeOrders.length > 0 && (
<form onSubmit={handleProductionExit} className="rounded-2xl border border-dark-border bg-dark-card p-5 shadow-sm">
<div className="flex flex-col gap-2 lg:flex-row lg:items-start lg:justify-between">
<div>
@@ -340,7 +375,7 @@ const ProductionOrders = () => {
className="mt-1 h-10 w-full rounded-lg border border-dark-border bg-dark-input px-3 text-sm font-semibold text-dark-text outline-none focus:border-brand-primary"
>
<option value="">Selecione...</option>
{summary.orders.filter(order => order.status !== 'finished' && order.status !== 'canceled').map(order => (
{activeOrders.map(order => (
<option key={order.id} value={order.id}>
{order.number || `#${order.id}`} · {order.productDescription}
</option>
@@ -396,9 +431,15 @@ const ProductionOrders = () => {
</div>
)}
</form>
)}
<div className="overflow-hidden rounded-2xl border border-dark-border bg-dark-card shadow-sm">
<div className="border-b border-dark-border p-5">
{statusMessage && (
<div className="mb-4 rounded-xl border border-dark-border bg-dark-input px-3 py-2 text-sm font-bold text-dark-muted">
{statusMessage}
</div>
)}
<div className="flex flex-col gap-4 xl:flex-row xl:items-center xl:justify-between">
<div className="relative w-full xl:max-w-xl">
<Search className="absolute left-3 top-1/2 h-5 w-5 -translate-y-1/2 text-dark-muted" />
@@ -472,8 +513,10 @@ const ProductionOrders = () => {
<tbody className="divide-y divide-dark-border">
{paginatedOrders.map((order: ProductionOrderItem) => {
const statusStyle = getStatusStyle(order.status, order.statusLabel);
const showDetails = hasProductionOrderDetails(order);
return (
<tr key={order.id} className="transition-colors hover:bg-dark-input/50">
<Fragment key={order.id}>
<tr className="transition-colors hover:bg-dark-input/50">
<td className="px-6 py-3 font-mono text-xs font-bold text-dark-text">{order.number || '-'}</td>
<td className="px-6 py-3 text-xs font-semibold text-dark-muted">{order.orderReference || '-'}</td>
<td className="px-6 py-3 text-xs font-semibold text-dark-muted">
@@ -515,12 +558,91 @@ const ProductionOrders = () => {
</span>
</td>
<td className="px-6 py-3">
<span className={`inline-flex items-center gap-2 rounded-full border px-2.5 py-1 text-[10px] font-bold uppercase tracking-wide ${statusStyle.className}`}>
<span className={`h-1.5 w-1.5 rounded-full ${statusStyle.dotClass}`} />
{statusStyle.label}
</span>
<label className="sr-only" htmlFor={`production-order-status-${order.id}`}>Status da OP</label>
<select
id={`production-order-status-${order.id}`}
value={order.status}
disabled={busyStatusOrderId === order.id}
onChange={(event) => void handleStatusChange(order, event.target.value as ProductionOrderStatusTab)}
className={`h-8 rounded-lg border bg-dark-input px-2 text-[10px] font-bold uppercase tracking-wide outline-none transition-colors focus:border-brand-primary disabled:cursor-not-allowed disabled:opacity-50 ${statusStyle.className}`}
>
{editableStatusOptions.map(option => (
<option key={option.value} value={option.value}>{option.label}</option>
))}
</select>
</td>
</tr>
{showDetails && (
<tr className="bg-dark-input/20">
<td colSpan={9} className="px-6 pb-5 pt-1">
<div className="grid grid-cols-1 gap-4 rounded-xl border border-dark-border bg-dark-card/80 p-4 xl:grid-cols-[1.4fr_1fr_1fr]">
<div>
<h3 className="text-xs font-bold uppercase tracking-widest text-dark-muted">Composição</h3>
{order.components.length ? (
<div className="mt-3 overflow-hidden rounded-lg border border-dark-border">
<table className="w-full text-xs">
<thead className="bg-dark-header text-dark-muted">
<tr>
<th className="px-3 py-2 text-left font-bold">Produto</th>
<th className="px-3 py-2 text-left font-bold">SKU</th>
<th className="px-3 py-2 text-right font-bold">Qtd.</th>
<th className="px-3 py-2 text-right font-bold">Total</th>
</tr>
</thead>
<tbody className="divide-y divide-dark-border">
{order.components.map(component => (
<tr key={component.id}>
<td className="px-3 py-2 font-semibold text-dark-text">{component.componentName}</td>
<td className="px-3 py-2 font-mono text-dark-muted">{component.componentSku || '-'}</td>
<td className="px-3 py-2 text-right font-semibold text-dark-muted">{formatQuantity(component.quantityPerUnit)} {component.unit}</td>
<td className="px-3 py-2 text-right font-bold text-dark-text">{formatQuantity(component.totalQuantity)} {component.unit}</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<p className="mt-3 text-xs font-semibold text-dark-muted">Sem composição sincronizada.</p>
)}
</div>
<div>
<h3 className="text-xs font-bold uppercase tracking-widest text-dark-muted">Etapas</h3>
{order.steps.length ? (
<div className="mt-3 divide-y divide-dark-border rounded-lg border border-dark-border">
{order.steps.map(step => (
<div key={step.id} className="grid grid-cols-[32px_1fr_auto] items-center gap-3 px-3 py-2 text-xs">
<span className="font-mono font-bold text-dark-muted">{step.stepNumber ?? '-'}</span>
<div>
<p className="font-bold text-dark-text">{step.name}</p>
<p className="mt-0.5 font-semibold text-dark-muted">{formatDate(step.startDate)} - {formatDate(step.endDate)}</p>
</div>
<span className="h-2.5 w-2.5 rounded-full" style={{ backgroundColor: step.color || 'var(--color-brand-primary)' }} title={step.status || 'Sem status'} />
</div>
))}
</div>
) : (
<p className="mt-3 text-xs font-semibold text-dark-muted">Sem etapas sincronizadas.</p>
)}
</div>
<div>
<h3 className="text-xs font-bold uppercase tracking-widest text-dark-muted">Observações</h3>
<dl className="mt-3 grid grid-cols-2 gap-2 text-xs">
<div><dt className="font-bold text-dark-muted">Fornecedor</dt><dd className="mt-0.5 font-semibold text-dark-text">{order.supplier || '-'}</dd></div>
<div><dt className="font-bold text-dark-muted">Lote</dt><dd className="mt-0.5 font-semibold text-dark-text">{order.lotCode || '-'}</dd></div>
<div><dt className="font-bold text-dark-muted">Rolos</dt><dd className="mt-0.5 font-semibold text-dark-text">{formatOptionalQuantity(order.rollQuantity)}</dd></div>
<div><dt className="font-bold text-dark-muted">Malha kg</dt><dd className="mt-0.5 font-semibold text-dark-text">{formatOptionalQuantity(order.fabricKg)}</dd></div>
<div><dt className="font-bold text-dark-muted">Ribana kg</dt><dd className="mt-0.5 font-semibold text-dark-text">{formatOptionalQuantity(order.ribKg)}</dd></div>
<div><dt className="font-bold text-dark-muted">Rendimento</dt><dd className="mt-0.5 font-semibold text-dark-text">{formatOptionalQuantity(order.yieldPiecesPerKg)}</dd></div>
</dl>
{order.notes && <p className="mt-3 whitespace-pre-wrap rounded-lg border border-dark-border bg-dark-input p-3 text-xs font-semibold text-dark-muted">{order.notes}</p>}
</div>
</div>
</td>
</tr>
)}
</Fragment>
);
})}
</tbody>
@@ -534,8 +656,15 @@ const ProductionOrders = () => {
</div>
<p className="mt-4 text-sm font-bold text-dark-text">Nenhuma ordem de produção encontrada.</p>
<p className="mt-1 max-w-md text-sm font-medium text-dark-muted">
Quando a sincronização com o Tiny estiver ativa, as ordens aparecerão aqui com status, produto, quantidade e marcadores.
Gere ordens pelo Plano de Corte para acompanhar status, produto, quantidade e baixa de material dentro do Graphs.
</p>
<Link
to="/cutting"
className="mt-5 inline-flex h-10 items-center gap-2 rounded-lg border border-brand-primary/35 bg-brand-primary/10 px-4 text-sm font-bold text-brand-primary transition-colors hover:bg-brand-primary/20"
>
<ClipboardList className="h-4 w-4" />
Ir para Plano de Corte
</Link>
</div>
)}

View File

@@ -7,10 +7,11 @@ import SkuPlanningModal from '../components/SkuPlanningModal';
import ProductTypeBadge from '../components/ProductTypeBadge';
import RefreshStatus from '../components/RefreshStatus';
import type { CutProductOverride, CuttingSettings, DateRange, ProductAnalyticsItem } from '../types';
import { exportToCSV, fetchCuttingSettings, fetchProductAnalytics, saveCuttingSettings } from '../dataService';
import { exportProductCompositions, exportToCSV, fetchCuttingSettings, fetchProductAnalytics, saveCuttingSettings } from '../dataService';
import { encodeProductGroupKey, parseProductName, sortProductSizes } from '../productParsing';
import { formatColorLabel } from '../displayFormatters';
import { getDominantProductType, getProductTypeConfig, productTypeOptions, resolveProductType, type ProductTypeKey } from '../productClassification';
import { getPlanningStock } from '../planningStock';
type StockRisk = 'rupture' | 'critical' | 'attention' | 'monitor' | 'healthy' | 'no_sales';
type StockStatusFilter = 'all' | StockRisk;
@@ -170,12 +171,15 @@ const Products = () => {
const [productTypeFilter, setProductTypeFilter] = useState<ProductTypeFilter>('all');
const [viewMode, setViewMode] = useState<ProductViewMode>('sku');
const [isFilterMenuOpen, setIsFilterMenuOpen] = useState(false);
const [isExportMenuOpen, setIsExportMenuOpen] = useState(false);
const filterMenuRef = useRef<HTMLDivElement>(null);
const exportMenuRef = useRef<HTMLDivElement>(null);
const [productAnalytics, setProductAnalytics] = useState<ProductAnalyticsItem[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [planningSettings, setPlanningSettings] = useState<CuttingSettings>({ familyYields: {}, productOverrides: {} });
const [editingProduct, setEditingProduct] = useState<ProductRow | null>(null);
const [isSavingPlanning, setIsSavingPlanning] = useState(false);
const [isExportingCompositions, setIsExportingCompositions] = useState(false);
const [currentPage, setCurrentPage] = useState(1);
const [itemsPerPage, setItemsPerPage] = useState(10);
@@ -235,16 +239,20 @@ const Products = () => {
};
useEffect(() => {
if (!isFilterMenuOpen) return;
if (!isFilterMenuOpen && !isExportMenuOpen) return;
const handlePointerDown = (event: PointerEvent) => {
if (!filterMenuRef.current?.contains(event.target as Node)) {
setIsFilterMenuOpen(false);
}
if (!exportMenuRef.current?.contains(event.target as Node)) {
setIsExportMenuOpen(false);
}
};
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
setIsFilterMenuOpen(false);
setIsExportMenuOpen(false);
}
};
@@ -255,14 +263,15 @@ const Products = () => {
document.removeEventListener('pointerdown', handlePointerDown);
document.removeEventListener('keydown', handleKeyDown);
};
}, [isFilterMenuOpen]);
}, [isFilterMenuOpen, isExportMenuOpen]);
const productsData = useMemo<ProductRow[]>(() => {
const days = getRangeDays(dateRange);
const normalizedSearch = searchTerm.trim().toLowerCase();
const skuRows = productAnalytics.map(product => {
const dailySales = product.quantitySold / days;
const risk = classifyStockRisk(product.stock, dailySales);
const planningStock = getPlanningStock(product.stock);
const risk = classifyStockRisk(planningStock, dailySales);
const style = riskStyles[risk];
const metadata = parseProductName(product.name);
const productType = resolveProductType(product.name, planningSettings.productOverrides[product.id]);
@@ -270,7 +279,7 @@ const Products = () => {
return {
...product,
dailySales,
daysOfCover: dailySales > 0 ? product.stock / dailySales : null,
daysOfCover: dailySales > 0 ? planningStock / dailySales : null,
risk,
riskLabel: style.label,
baseName: metadata.baseName,
@@ -302,10 +311,11 @@ const Products = () => {
const quantitySold = group.reduce((total, product) => total + product.quantitySold, 0);
const revenue = group.reduce((total, product) => total + product.revenue, 0);
const stock = group.reduce((total, product) => total + product.stock, 0);
const planningStock = group.reduce((total, product) => total + getPlanningStock(product.stock), 0);
const orderLineCount = group.reduce((total, product) => total + product.orderLineCount, 0);
const dailySales = quantitySold / days;
const daysOfCover = dailySales > 0 ? stock / dailySales : null;
const risk = classifyStockRisk(stock, dailySales);
const daysOfCover = dailySales > 0 ? planningStock / dailySales : null;
const risk = classifyStockRisk(planningStock, dailySales);
const style = riskStyles[risk];
const colorTotals = new Map<string, number>();
const sizeTotals = new Map<string, number>();
@@ -450,7 +460,21 @@ const Products = () => {
}}
/>
<div ref={exportMenuRef} className="relative">
<button
type="button"
onClick={() => setIsExportMenuOpen(current => !current)}
aria-expanded={isExportMenuOpen}
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="Escolher exportação"
>
<Download size={16} className="text-brand-primary" />
<span>Exportar</span>
</button>
{isExportMenuOpen && (
<div className="absolute right-0 z-20 mt-2 w-56 overflow-hidden rounded-xl border border-dark-border bg-dark-card p-1 shadow-xl">
<button
type="button"
onClick={() => {
const exportData = productsData.map(product => ({
'Tipo': viewMode === 'group' ? 'Grupo' : 'SKU',
@@ -471,13 +495,33 @@ const Products = () => {
'Receita Gerada (R$)': product.revenue.toFixed(2).replace('.', ',')
}));
exportToCSV(exportData, `${viewMode === 'group' ? 'grupos_produtos' : 'produtos'}_${new Date().toISOString().split('T')[0]}.csv`);
setIsExportMenuOpen(false);
}}
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"
className="w-full rounded-lg px-3 py-2 text-left text-sm font-semibold text-dark-text hover:bg-dark-input cursor-pointer"
>
<Download size={16} className="text-brand-primary" />
<span className="hidden sm:inline">Exportar</span>
Produtos (CSV)
</button>
<button
type="button"
disabled={isExportingCompositions}
onClick={async () => {
setIsExportingCompositions(true);
try {
await exportProductCompositions();
setIsExportMenuOpen(false);
} catch (error) {
window.alert(error instanceof Error ? error.message : 'Não foi possível exportar as composições.');
} finally {
setIsExportingCompositions(false);
}
}}
className="w-full rounded-lg px-3 py-2 text-left text-sm font-semibold text-dark-text hover:bg-dark-input cursor-pointer disabled:cursor-wait disabled:opacity-60"
>
{isExportingCompositions ? 'Preparando…' : 'Composições (JSON)'}
</button>
</div>
)}
</div>
</div>
</div>

View File

@@ -1,11 +1,12 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { Loader2, Package, RefreshCw, Ruler, Save, Tags, Trash2 } from 'lucide-react';
import { Link, useSearchParams } from 'react-router-dom';
import { ClipboardList, Loader2, Package, RefreshCw, Ruler, Save, Tags, Trash2, Upload } from 'lucide-react';
import {
deleteCatalogCategory,
deleteCatalogProduct,
deleteConsumptionReference,
fetchCatalogSummary,
importProductCompositions,
saveCatalogCategory,
saveCatalogProduct,
saveConsumptionReference
@@ -92,6 +93,18 @@ const getAverageYield = (reference: ConsumptionReference) => {
return reference.generalYield;
};
const getReferenceSourceLabel = (reference: ConsumptionReference) => (
reference.source === 'tiny_op'
? 'Tiny OP'
: reference.source === 'tiny_structure' ? 'Tiny Estrutura' : 'Manual'
);
const formatConsumptionPerPiece = (reference: ConsumptionReference) => {
if (!reference.consumptionQuantity) return '';
const unit = reference.consumptionUnit || 'un.';
return `${formatNumber(reference.consumptionQuantity, 4)} ${unit}/peça`;
};
const Registrations = () => {
const [searchParams] = useSearchParams();
const [catalog, setCatalog] = useState<CatalogSummary>(emptyCatalog);
@@ -101,6 +114,7 @@ const Registrations = () => {
const [status, setStatus] = useState<SaveStatus>('idle');
const [feedback, setFeedback] = useState('');
const appliedSkuPrefillRef = useRef('');
const compositionFileInputRef = useRef<HTMLInputElement>(null);
const [categoryForm, setCategoryForm] = useState({ name: '', description: '' });
const [productForm, setProductForm] = useState(defaultProductForm);
@@ -313,6 +327,29 @@ const Registrations = () => {
setSizeYields({});
}, 'Referencia salva.');
const importCompositionFile = async (file: File | null) => {
if (!file) return;
setStatus('saving');
setFeedback('Importando composições...');
try {
const payload = JSON.parse(await file.text()) as unknown;
const result = await importProductCompositions(payload);
await loadCatalog();
setStatus(result.failed ? 'error' : 'saved');
setFeedback(
`${result.imported} composições importadas, ${result.referenceCount} referências criadas/atualizadas` +
`${result.failed ? `, ${result.failed} falhas` : ''}.`
);
} catch (error) {
setStatus('error');
setFeedback(error instanceof Error ? error.message : 'Não foi possível importar o arquivo.');
} finally {
if (compositionFileInputRef.current) compositionFileInputRef.current.value = '';
}
};
const removeCategory = (category: CatalogCategory) => runAction(
() => deleteCatalogCategory(category.id),
`Categoria ${category.name} excluida.`
@@ -340,7 +377,7 @@ const Registrations = () => {
const tabItems: Array<{ key: RegistrationTab; label: string; icon: typeof Package; count: number }> = [
{ key: 'products', label: 'Produtos', icon: Package, count: catalog.products.length },
{ key: 'categories', label: 'Categorias', icon: Tags, count: catalog.categories.length },
{ key: 'references', label: 'Referencia de Consumo', icon: Ruler, count: catalog.consumptionReferences.length }
{ key: 'references', label: 'Referência de Consumo', icon: Ruler, count: catalog.consumptionReferences.length }
];
return (
@@ -349,18 +386,43 @@ const Registrations = () => {
<div>
<h1 className="mb-2 text-2xl font-bold text-zinc-900 dark:text-dark-text">Cadastros</h1>
<p className="font-medium text-zinc-500 dark:text-dark-muted">
Base de produtos, categorias e referencias de consumo usadas pelo corte.
Fonte de verdade para produtos, matérias-primas e referências de consumo.
</p>
</div>
<div className="flex flex-wrap gap-2">
<input
ref={compositionFileInputRef}
type="file"
accept="application/json,.json"
className="hidden"
onChange={(event) => void importCompositionFile(event.target.files?.[0] || null)}
/>
<button
type="button"
onClick={() => compositionFileInputRef.current?.click()}
disabled={status === 'saving'}
className="inline-flex h-10 items-center justify-center gap-2 rounded-xl border border-dark-border bg-dark-card px-4 text-sm font-bold text-dark-text transition-colors hover:border-brand-primary disabled:cursor-not-allowed disabled:opacity-60 cursor-pointer"
>
<Upload className="h-4 w-4 text-brand-primary" />
Importar composições
</button>
<Link
to="/planning-issues"
className="inline-flex h-10 items-center justify-center gap-2 rounded-xl border border-dark-border bg-dark-card px-4 text-sm font-bold text-dark-text transition-colors hover:border-brand-primary"
>
<ClipboardList className="h-4 w-4 text-brand-primary" />
Dados Pendentes
</Link>
<button
type="button"
onClick={() => void loadCatalog()}
className="inline-flex items-center justify-center gap-2 rounded-xl border border-dark-border bg-dark-card px-4 py-2.5 text-sm font-bold text-dark-text transition-colors hover:border-brand-primary cursor-pointer"
className="inline-flex h-10 items-center justify-center gap-2 rounded-xl border border-dark-border bg-dark-card px-4 text-sm font-bold text-dark-text transition-colors hover:border-brand-primary cursor-pointer"
>
<RefreshCw className="h-4 w-4 text-brand-primary" />
Atualizar
</button>
</div>
</div>
<div className="grid grid-cols-1 gap-3 md:grid-cols-3">
{tabItems.map(item => {
@@ -654,6 +716,13 @@ const Registrations = () => {
<span className="rounded-full border border-dark-border bg-dark-input px-2 py-0.5 text-[10px] font-bold text-dark-muted">
{Object.keys(reference.sizeYields || {}).length ? 'Por tamanho' : 'Geral'}
</span>
<span className={`rounded-full border px-2 py-0.5 text-[10px] font-bold ${
reference.source === 'tiny_op' || reference.source === 'tiny_structure'
? 'border-sky-400/30 bg-sky-400/10 text-sky-300'
: 'border-dark-border bg-dark-input text-dark-muted'
}`}>
{getReferenceSourceLabel(reference)}
</span>
</div>
<h3 className="mt-1 truncate text-sm font-bold text-dark-text">{reference.productName}</h3>
<p className="mt-1 text-xs font-semibold text-dark-muted">
@@ -671,10 +740,19 @@ const Registrations = () => {
</div>
<div className="flex shrink-0 items-start gap-3">
<div className="text-right">
<div className="text-lg font-bold text-emerald-300">{formatNumber(getAverageYield(reference), 3)} /kg</div>
<div className="text-[10px] font-bold uppercase tracking-widest text-dark-muted">
{Object.keys(reference.sizeYields || {}).length ? 'media' : 'geral'}
<div className="text-lg font-bold text-emerald-300">
{formatConsumptionPerPiece(reference) || `${formatNumber(getAverageYield(reference), 3)} pç/kg`}
</div>
<div className="text-[10px] font-bold uppercase tracking-widest text-dark-muted">
{reference.consumptionQuantity
? 'consumo'
: Object.keys(reference.sizeYields || {}).length ? 'media' : 'geral'}
</div>
{reference.consumptionQuantity && getAverageYield(reference) ? (
<div className="mt-1 text-[11px] font-semibold text-dark-muted">
{formatNumber(getAverageYield(reference), 3)} /kg
</div>
) : null}
</div>
<button type="button" onClick={() => void removeReference(reference)} className="inline-flex h-9 w-9 items-center justify-center rounded-lg border border-dark-border bg-dark-input text-dark-muted transition-colors hover:border-red-400/40 hover:text-red-300 cursor-pointer">
<Trash2 className="h-4 w-4" />

View File

@@ -10,6 +10,7 @@ import type { DateRange, ProductAnalyticsItem, ProductionOrderItem } from '../ty
import { exportToCSV, fetchProductAnalytics, fetchProductionOrders } from '../dataService';
import { encodeProductGroupKey, parseProductName, sortProductSizes } from '../productParsing';
import { formatColorLabel } from '../displayFormatters';
import { getPlanningStock } from '../planningStock';
type ReplenishmentStatus = 'need' | 'covered' | 'no_sales' | 'no_stock';
type ReplenishmentFilter = 'all' | ReplenishmentStatus;
@@ -174,7 +175,7 @@ const Replenishment = () => {
const dailySales = product.quantitySold / rangeDays;
const projectedDemand = dailySales * targetCoverageDays;
const openProductionQuantity = openProductionByProductId[product.id] || 0;
const availableQuantity = product.stock + openProductionQuantity;
const availableQuantity = getPlanningStock(product.stock) + openProductionQuantity;
const rawNeed = projectedDemand - availableQuantity;
const suggestedQuantity = Math.max(0, Math.ceil(rawNeed));
const daysOfCover = dailySales > 0 ? availableQuantity / dailySales : null;

View File

@@ -0,0 +1,89 @@
import { useEffect, useMemo, useState } from 'react';
import { Link, useOutletContext, useParams } from 'react-router-dom';
import { ArrowLeft } from 'lucide-react';
import DateRangePicker from '../components/DateRangePicker';
import PaginationControls from '../components/PaginationControls';
import { fetchSellerDetailsAnalytics } from '../dataService';
import { formatDisplayName, removeTrailingSellerId } from '../displayFormatters';
import type { DateRange, SellerDetailsAnalytics } from '../types';
const formatNumber = (value: number) => new Intl.NumberFormat('pt-BR', { maximumFractionDigits: 0 }).format(value);
const formatCurrency = (value: number) => new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(value);
const SellerDetailsSkeleton = () => (
<div className="space-y-6" aria-label="Carregando detalhes do vendedor">
<div className="flex flex-col gap-4 xl:flex-row xl:items-start xl:justify-between">
<div className="space-y-3"><div className="skeleton h-4 w-24" /><div className="skeleton h-7 w-64" /><div className="skeleton h-4 w-96 max-w-full" /></div>
<div className="skeleton h-11 w-56" />
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-4">
{[1, 2, 3, 4].map(item => <div key={item} className="rounded-2xl border border-dark-border bg-dark-card p-5 shadow-sm"><div className="skeleton h-3 w-20" /><div className="skeleton mt-3 h-7 w-32" /></div>)}
</div>
<div className="grid grid-cols-1 gap-6 xl:grid-cols-[1.3fr_0.7fr]">
<div className="rounded-2xl border border-dark-border bg-dark-card p-5 shadow-sm"><div className="skeleton h-5 w-40" /><div className="skeleton mt-2 h-4 w-48" /><div className="mt-6 space-y-4">{[1, 2, 3, 4, 5, 6].map(item => <div key={item} className="grid grid-cols-[52px_1fr_72px] items-center gap-3"><div className="skeleton h-3" /><div className="skeleton h-2" /><div className="skeleton h-3" /></div>)}</div></div>
<div className="rounded-2xl border border-dark-border bg-dark-card p-5 shadow-sm"><div className="skeleton h-5 w-20" /><div className="skeleton mt-2 h-4 w-40" /><div className="mt-6 space-y-3">{[1, 2, 3].map(item => <div key={item} className="rounded-xl border border-dark-border p-3"><div className="skeleton h-4 w-28" /><div className="skeleton mt-2 h-3 w-40" /></div>)}</div></div>
</div>
<div className="grid grid-cols-1 gap-6 xl:grid-cols-2">{[1, 2].map(item => <div key={item} className="overflow-hidden rounded-2xl border border-dark-border bg-dark-card shadow-sm"><div className="border-b border-dark-border p-5"><div className="skeleton h-5 w-40" /></div><div className="space-y-0">{[1, 2, 3, 4].map(row => <div key={row} className="flex items-center justify-between gap-4 border-b border-dark-border p-4 last:border-b-0"><div><div className="skeleton h-4 w-48" /><div className="skeleton mt-2 h-3 w-20" /></div><div className="skeleton h-4 w-20" /></div>)}</div></div>)}</div>
</div>
);
const SellerDetails = () => {
const { sellerId = '' } = useParams<{ sellerId: string }>();
const { dateRange, setDateRange } = useOutletContext<{ dateRange: DateRange; setDateRange: (range: DateRange) => void }>();
const [details, setDetails] = useState<SellerDetailsAnalytics | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [trendPage, setTrendPage] = useState(1);
const trendPageSize = 14;
useEffect(() => {
let isMounted = true;
const load = async () => {
setIsLoading(true);
const result = await fetchSellerDetailsAnalytics(sellerId, dateRange);
if (isMounted) { setDetails(result); setIsLoading(false); }
};
void load();
return () => { isMounted = false; };
}, [dateRange, sellerId]);
const maxRevenue = useMemo(() => Math.max(...(details?.chartData.map(item => item.revenue) || [1]), 1), [details]);
const trendTotalPages = Math.ceil((details?.chartData.length || 0) / trendPageSize);
const safeTrendPage = Math.min(trendPage, trendTotalPages || 1);
const trendStartIndex = (safeTrendPage - 1) * trendPageSize;
const paginatedTrend = details?.chartData.slice(trendStartIndex, trendStartIndex + trendPageSize) || [];
if (isLoading && !details) return <SellerDetailsSkeleton />;
if (!details) return <div className="rounded-2xl border border-dark-border bg-dark-card p-10 text-center"><p className="font-bold text-dark-text">Vendedor não encontrado no período.</p><Link to="/sellers" className="mt-4 inline-flex text-sm font-bold text-brand-primary">Voltar para vendedores</Link></div>;
const { seller } = details;
return (
<div className="space-y-6">
<div className="flex flex-col gap-4 xl:flex-row xl:items-start xl:justify-between">
<div>
<Link to="/sellers" className="inline-flex items-center gap-2 text-sm font-bold text-dark-muted transition-colors hover:text-dark-text"><ArrowLeft className="h-4 w-4" /> Vendedores</Link>
<h1 className="mt-4 text-2xl font-bold text-dark-text">{formatDisplayName(removeTrailingSellerId(seller.name))}</h1>
<p className="mt-2 font-medium text-dark-muted">Carteira, produtos e resultado comercial no período selecionado.</p>
</div>
<DateRangePicker dateRange={dateRange} onChange={range => { setDateRange(range); setTrendPage(1); }} />
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-4">
{[['Receita', formatCurrency(seller.revenue), 'text-brand-primary'], ['Pedidos', formatNumber(seller.orderCount), 'text-dark-text'], ['Clientes', formatNumber(seller.customerCount), 'text-dark-text'], ['Ticket médio', formatCurrency(seller.averageTicket), 'text-dark-text']].map(([label, value, color]) => <div key={label} className="rounded-2xl border border-dark-border bg-dark-card p-5 shadow-sm"><p className="text-xs font-bold uppercase tracking-widest text-dark-muted">{label}</p><p className={`mt-2 text-2xl font-bold ${color}`}>{value}</p></div>)}
</div>
<div className="grid grid-cols-1 gap-6 xl:grid-cols-[1.3fr_0.7fr]">
<section className="overflow-hidden rounded-2xl border border-dark-border bg-dark-card shadow-sm">
<div className="p-5"><h2 className="text-base font-bold text-dark-text">Evolução da receita</h2><p className="mt-1 text-sm font-semibold text-dark-muted">Vendas por dia no período.</p></div>
{details.chartData.length ? <>
<div className="space-y-3 px-5 pb-5">{paginatedTrend.map(point => <div key={point.date} className="grid grid-cols-[52px_1fr_auto] items-center gap-3 text-xs sm:grid-cols-[76px_1fr_auto]"><span className="font-semibold text-dark-muted">{new Date(`${point.date}T12:00:00`).toLocaleDateString('pt-BR', { day: '2-digit', month: '2-digit' })}</span><div className="h-2 overflow-hidden rounded-full bg-dark-input"><div className="h-full rounded-full bg-brand-primary" style={{ width: `${(point.revenue / maxRevenue) * 100}%` }} /></div><span className="font-bold text-dark-text">{formatCurrency(point.revenue)}</span></div>)}</div>
<PaginationControls totalItems={details.chartData.length} currentPage={safeTrendPage} totalPages={trendTotalPages} pageSize={trendPageSize} pageSizeOptions={[14]} itemLabel="dias" pageSizeLabel="dias por página" startIndex={trendStartIndex} endIndex={Math.min(trendStartIndex + trendPageSize, details.chartData.length)} onPageChange={setTrendPage} onPageSizeChange={() => setTrendPage(1)} className="border-t border-dark-border px-4 py-3" />
</> : <p className="px-5 py-10 text-center text-sm font-semibold text-dark-muted">Sem vendas no período.</p>}
</section>
<section className="rounded-2xl border border-dark-border bg-dark-card p-5 shadow-sm"><h2 className="text-base font-bold text-dark-text">Canais</h2><p className="mt-1 text-sm font-semibold text-dark-muted">Receita por marketplace.</p><div className="mt-5 space-y-3">{details.marketplaces.map(item => <div key={item.name} className="rounded-xl border border-dark-border bg-dark-input/50 p-3"><p className="text-sm font-bold text-dark-text">{item.name}</p><p className="mt-1 text-xs font-semibold text-brand-primary">{formatCurrency(item.revenue)} · {formatNumber(item.orderCount)} pedidos</p></div>)}</div></section>
</div>
<div className="grid grid-cols-1 gap-6 xl:grid-cols-2">
<section className="overflow-hidden rounded-2xl border border-dark-border bg-dark-card shadow-sm"><div className="border-b border-dark-border p-5"><h2 className="text-base font-bold text-dark-text">Produtos mais vendidos</h2></div><div className="divide-y divide-dark-border">{details.topProducts.map(product => <Link key={product.id} to={`/products/${product.id}`} className="flex items-center justify-between gap-4 p-4 transition-colors hover:bg-dark-input/35"><div className="min-w-0"><p className="truncate text-sm font-bold text-dark-text">{product.name}</p><p className="mt-1 text-xs font-semibold text-dark-muted">{formatNumber(product.quantitySold)} un. vendidas</p></div><span className="shrink-0 text-sm font-bold text-brand-primary">{formatCurrency(product.revenue)}</span></Link>)}</div></section>
<section className="overflow-hidden rounded-2xl border border-dark-border bg-dark-card shadow-sm"><div className="border-b border-dark-border p-5"><h2 className="text-base font-bold text-dark-text">Carteira no período</h2></div><div className="divide-y divide-dark-border">{details.topClients.map(client => <Link key={client.clientToken} to={`/clients/${client.clientToken}`} className="flex items-center justify-between gap-4 p-4 transition-colors hover:bg-dark-input/35"><div className="min-w-0"><p className="truncate text-sm font-bold text-dark-text">{client.name}</p><p className="mt-1 text-xs font-semibold text-dark-muted">{formatNumber(client.orderCount)} pedidos</p></div><span className="shrink-0 text-sm font-bold text-brand-primary">{formatCurrency(client.totalSpent)}</span></Link>)}</div></section>
</div>
</div>
);
};
export default SellerDetails;

150
src/pages/Sellers.tsx Normal file
View File

@@ -0,0 +1,150 @@
import { useEffect, useMemo, useState } from 'react';
import { Link, useOutletContext } from 'react-router-dom';
import { BriefcaseBusiness, Eye, Search } from 'lucide-react';
import DateRangePicker from '../components/DateRangePicker';
import PaginationControls from '../components/PaginationControls';
import RefreshStatus from '../components/RefreshStatus';
import { fetchSellerAnalytics } from '../dataService';
import { formatDisplayName, removeTrailingSellerId } from '../displayFormatters';
import type { DateRange, SellerAnalyticsItem } from '../types';
const formatNumber = (value: number) => new Intl.NumberFormat('pt-BR', { maximumFractionDigits: 0 }).format(value);
const formatCurrency = (value: number) => new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(value);
const SellersTableSkeleton = () => (
<div className="overflow-x-auto">
<div className="min-w-[980px]">
<div className="grid grid-cols-[90px_1.5fr_repeat(5,120px)_80px] gap-5 border-b border-dark-border bg-dark-header px-6 py-4">
{[1, 2, 3, 4, 5, 6, 7, 8].map(item => <div key={item} className="skeleton h-3" />)}
</div>
<div className="divide-y divide-dark-border">
{[1, 2, 3, 4, 5, 6].map(row => <div key={row} className="grid grid-cols-[90px_1.5fr_repeat(5,120px)_80px] items-center gap-5 px-6 py-3"><div className="skeleton h-7 w-7 rounded-full" /><div><div className="skeleton h-4 w-40" /><div className="skeleton mt-2 h-3 w-20" /></div>{[1, 2, 3, 4, 5].map(column => <div key={column} className="skeleton h-4" />)}<div className="skeleton h-8 w-8 rounded-lg" /></div>)}
</div>
</div>
</div>
);
const Sellers = () => {
const { dateRange, setDateRange } = useOutletContext<{ dateRange: DateRange; setDateRange: (range: DateRange) => void }>();
const [sellers, setSellers] = useState<SellerAnalyticsItem[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [search, setSearch] = useState('');
const [currentPage, setCurrentPage] = useState(1);
const [itemsPerPage, setItemsPerPage] = useState(10);
useEffect(() => {
let isMounted = true;
const load = async () => {
setIsLoading(true);
const rows = await fetchSellerAnalytics(dateRange);
if (isMounted) {
setSellers(rows);
setIsLoading(false);
}
};
void load();
return () => { isMounted = false; };
}, [dateRange]);
const visibleSellers = useMemo(() => {
const term = search.trim().toLocaleLowerCase('pt-BR');
if (!term) return sellers;
return sellers.filter(seller => `${seller.name} ${seller.id}`.toLocaleLowerCase('pt-BR').includes(term));
}, [search, sellers]);
const totalRevenue = sellers.reduce((sum, seller) => sum + seller.revenue, 0);
const totalOrders = sellers.reduce((sum, seller) => sum + seller.orderCount, 0);
const totalCustomers = sellers.reduce((sum, seller) => sum + seller.customerCount, 0);
const topSeller = sellers[0];
const totalPages = Math.ceil(visibleSellers.length / itemsPerPage);
const safeCurrentPage = Math.min(currentPage, totalPages || 1);
const startIndex = (safeCurrentPage - 1) * itemsPerPage;
const paginatedSellers = visibleSellers.slice(startIndex, startIndex + itemsPerPage);
return (
<div className="space-y-6">
<div className="flex flex-col gap-4 xl:flex-row xl:items-center xl:justify-between">
<div>
<h1 className="text-2xl font-bold text-dark-text">Vendedores</h1>
<p className="mt-2 font-medium text-dark-muted">Performance comercial, carteira e produtos vendidos por responsável.</p>
</div>
<DateRangePicker dateRange={dateRange} onChange={setDateRange} />
</div>
<RefreshStatus isRefreshing={isLoading && sellers.length > 0} />
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-4">
<div className="rounded-2xl border border-dark-border bg-dark-card p-5 shadow-sm">
<p className="text-xs font-bold uppercase tracking-widest text-dark-muted">Receita da equipe</p>
{isLoading && !sellers.length ? <div className="skeleton mt-3 h-7 w-36" /> : <p className="mt-2 text-2xl font-bold text-brand-primary">{formatCurrency(totalRevenue)}</p>}
</div>
<div className="rounded-2xl border border-dark-border bg-dark-card p-5 shadow-sm">
<p className="text-xs font-bold uppercase tracking-widest text-dark-muted">Pedidos</p>
{isLoading && !sellers.length ? <div className="skeleton mt-3 h-8 w-20" /> : <p className="mt-2 text-3xl font-bold text-dark-text">{formatNumber(totalOrders)}</p>}
</div>
<div className="rounded-2xl border border-dark-border bg-dark-card p-5 shadow-sm">
<p className="text-xs font-bold uppercase tracking-widest text-dark-muted">Clientes atendidos</p>
{isLoading && !sellers.length ? <div className="skeleton mt-3 h-8 w-20" /> : <p className="mt-2 text-3xl font-bold text-dark-text">{formatNumber(totalCustomers)}</p>}
</div>
<div className="rounded-2xl border border-dark-border bg-dark-card p-5 shadow-sm">
<p className="text-xs font-bold uppercase tracking-widest text-dark-muted">Maior receita</p>
{isLoading && !sellers.length ? <><div className="skeleton mt-3 h-5 w-40" /><div className="skeleton mt-2 h-3 w-24" /></> : <><p className="mt-2 truncate text-lg font-bold text-dark-text">{topSeller ? formatDisplayName(removeTrailingSellerId(topSeller.name)) : '—'}</p><p className="mt-1 text-xs font-semibold text-dark-muted">{topSeller ? formatCurrency(topSeller.revenue) : 'Sem vendas no período'}</p></>}
</div>
</div>
<div className="rounded-2xl border border-dark-border bg-dark-card shadow-sm">
<div className="flex flex-col gap-4 border-b border-dark-border p-4 sm:p-5 lg:flex-row lg:items-center lg:justify-between">
<div>
<h2 className="text-base font-bold text-dark-text">Equipe comercial</h2>
<p className="mt-1 text-sm font-semibold text-dark-muted">Selecione um vendedor para ver carteira, produtos e evolução.</p>
</div>
<label className="relative block w-full lg:max-w-sm">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-dark-muted" />
<input value={search} onChange={event => { setSearch(event.target.value); setCurrentPage(1); }} placeholder="Buscar vendedor..." className="h-11 w-full rounded-xl border border-dark-border bg-dark-input pl-10 pr-3 text-sm font-semibold text-dark-text outline-none placeholder:text-dark-muted focus:border-brand-primary" />
</label>
</div>
{isLoading && !sellers.length ? (
<SellersTableSkeleton />
) : visibleSellers.length ? (
<>
<div className="overflow-x-auto">
<table className="w-full min-w-[980px] text-left text-sm">
<thead className="border-b border-dark-border bg-dark-header text-dark-muted">
<tr>
<th className="px-6 py-4 text-[10px] font-bold uppercase tracking-wider">Posição</th>
<th className="px-6 py-4 text-[10px] font-bold uppercase tracking-wider">Nome do vendedor</th>
<th className="px-6 py-4 text-[10px] font-bold uppercase tracking-wider">Receita</th>
<th className="px-6 py-4 text-[10px] font-bold uppercase tracking-wider">Pedidos</th>
<th className="px-6 py-4 text-[10px] font-bold uppercase tracking-wider">Clientes</th>
<th className="px-6 py-4 text-[10px] font-bold uppercase tracking-wider">Ticket médio</th>
<th className="px-6 py-4 text-[10px] font-bold uppercase tracking-wider">Produtos vendidos</th>
<th className="px-6 py-4 text-right text-[10px] font-bold uppercase tracking-wider">Ações</th>
</tr>
</thead>
<tbody className="divide-y divide-dark-border">
{paginatedSellers.map((seller, index) => (
<tr key={seller.id} className="group transition-colors hover:bg-dark-input/50">
<td className="px-6 py-2.5"><span className="inline-flex h-7 w-7 items-center justify-center rounded-full bg-dark-border text-xs font-bold text-dark-muted">{startIndex + index + 1}</span></td>
<td className="px-6 py-2.5"><p className="font-semibold text-dark-text">{formatDisplayName(removeTrailingSellerId(seller.name))}</p><p className="mt-1 text-[10px] font-semibold text-dark-muted">{seller.id.startsWith('name:') ? 'Sem ID Tiny' : `ID ${seller.id}`}</p></td>
<td className="px-6 py-2.5 font-bold text-brand-primary">{formatCurrency(seller.revenue)}</td>
<td className="px-6 py-2.5 font-semibold text-dark-text">{formatNumber(seller.orderCount)}</td>
<td className="px-6 py-2.5 font-semibold text-dark-text">{formatNumber(seller.customerCount)}</td>
<td className="px-6 py-2.5 font-semibold text-dark-text">{formatCurrency(seller.averageTicket)}</td>
<td className="px-6 py-2.5 text-xs font-medium text-dark-muted">{formatNumber(seller.quantitySold)} un. ({formatNumber(seller.orderCount)} {seller.orderCount === 1 ? 'pedido' : 'pedidos'})</td>
<td className="px-6 py-2.5 text-right"><Link to={`/sellers/${encodeURIComponent(seller.id)}`} className="inline-flex h-8 w-8 items-center justify-center rounded-lg bg-brand-primary/10 text-brand-primary transition-opacity hover:opacity-80" title={`Ver detalhes de ${seller.name}`} aria-label={`Ver detalhes de ${seller.name}`}><Eye className="h-3.5 w-3.5" /></Link></td>
</tr>
))}
</tbody>
</table>
</div>
<PaginationControls totalItems={visibleSellers.length} currentPage={safeCurrentPage} totalPages={totalPages} pageSize={itemsPerPage} pageSizeOptions={[10, 20, 50]} itemLabel="vendedores" pageSizeLabel="itens por página" startIndex={startIndex} endIndex={Math.min(startIndex + itemsPerPage, visibleSellers.length)} onPageChange={setCurrentPage} onPageSizeChange={pageSize => { setItemsPerPage(pageSize); setCurrentPage(1); }} />
</>
) : (
<div className="flex flex-col items-center px-6 py-16 text-center"><BriefcaseBusiness className="h-10 w-10 text-dark-muted" /><p className="mt-4 text-sm font-bold text-dark-text">Nenhum vendedor encontrado.</p><p className="mt-1 text-sm font-semibold text-dark-muted">Ajuste a busca ou o período selecionado.</p></div>
)}
</div>
</div>
);
};
export default Sellers;

File diff suppressed because it is too large Load Diff

1
src/planningStock.ts Normal file
View File

@@ -0,0 +1 @@
export const getPlanningStock = (stock: number) => Math.max(0, stock);

View File

@@ -121,13 +121,22 @@ export const classifyProductType = (name: string): ProductTypeKey => {
if (!normalizedName) return 'unknown';
if (has(normalizedName, /\bSALDO ESTOQUE\b/)) return 'ignore_from_planning';
if (has(normalizedName, /^(?:\d+\s+)?(?:MALHA|RIBANA)\b/)) return 'raw_material';
if (has(normalizedName, /\b(?:RETALHO|RESIDUO)\s+(?:DE\s+)?(?:MALHA|MOLETOM)\b/)) return 'raw_material';
if (has(normalizedName, /\bFIO\b.*\bMALHARIA\b/)) return 'raw_material';
if (has(normalizedName, /\b(?:LINHA|FIO) PARA COSTURA\b/)) return 'raw_material';
if (has(normalizedName, /\bATACADOR\b/)) return 'raw_material';
if (has(normalizedName, /\bILHOS\b/)) return 'raw_material';
if (has(normalizedName, /ETIQUETA|TAG|FITA/)) return 'raw_material';
if (has(normalizedName, /\bCREDITO\b/)) return 'ignore_from_planning';
if (has(normalizedName, /\bKIT\b/)) return 'kit_bundle';
if (has(normalizedName, /TRANSPARENTE PP|SACO DE SEGURANCA|MILHEIRO|SACOLA|EMBALAGEM/)) return 'packaging';
if (has(normalizedName, /PRENSA|MAQUINA|OVERLOCK/)) return 'equipment';
if (has(normalizedName, /FRETE|SERVICO|TRANSPORTE|MOTOTAXI|TECELAGEM|TINTURARIA/)) return 'service';
if (has(normalizedName, /DUMPER|CABO FLAT|WIPPER|PRIMER|FLUIDO DE LIMPEZA|MISTURADOR|CABECA I3200|PECA DE MAQUINA/)) return 'machine_part';
if (has(normalizedName, /TINTA DTF|FILME DTF|POLIAMIDA.*DTF|DTF ROLO|PO PARA DTF/)) return 'dtf_input';
if (has(normalizedName, /IMPRESSAO DTF|CORRECAO IMPRESSAO|ESTAMPA|PERSONALIZACAO/)) return 'dtf_service';
if (has(normalizedName, /PRENSA|MAQUINA|OVERLOCK|OVERLOK|GALONEIRA|PRATELEIRA/)) return 'equipment';
if (has(normalizedName, /FRETE|SERVICO|TRANSPORTE|MOTOTAXI|TECELAGEM|TINTURARIA|MAO DE OBRA/)) return 'service';
if (has(normalizedName, /DUMPER|CABO FLAT|WIPPER|PRIMER|FLUIDO DE LIMPEZA|MISTURADOR|CABECA I3200|CABECA DE IMPRESSAO|SENSOR INFRAVERMELHO|CAPSULA FILTRO|PECA DE MAQUINA/)) return 'machine_part';
if (has(normalizedName, /TINTA DTF|FILME DTF|POLIAMIDA.*DTF|DTF ROLO|PO PARA DTF|BOMBA DE TINTA/)) return 'dtf_input';
if (has(normalizedName, /IMPRESSAO DTF|IMPRESSAO UV|CORRECAO IMPRESSAO|ESTAMPA|PERSONALIZACAO/)) return 'dtf_service';
if (has(normalizedName, /MALHA|RIBANA|FIO|TECIDO|RESIDUO|RETALHO|PIMA|ALGODAO/)) {
if (!has(normalizedName, /CAMISETA|MOLETOM|REGATA|BONE|CHINELO|OVERSIZE|OVER SIZE/)) {
return 'raw_material';

View File

@@ -1,8 +1,54 @@
const sizeOrder = ['2', '4', '6', '8', '10', '12', '14', '16', 'PP', 'P', 'M', 'G', 'GG', 'XG', 'G1', 'G2', 'G3', 'G4', 'G5'];
const sizeSet = new Set(sizeOrder);
const knownColors = [
'BRANCO + PRETO',
'CINZA/PRETO',
'VERDE BANDEIRA',
'VERDE MILITAR',
'AZUL MARINHO',
'CINZA GRAFITE',
'AMARELO',
'BRANCO',
'GRAFITE',
'MARINHO',
'VERMELHO',
'BORDO',
'CAFE',
'CAQUI',
'CHUMBO',
'CINZA',
'MARROM',
'PEROLA',
'PRETO',
'ROYAL',
'ROSA',
'ROXO',
'VERDE',
'AZUL',
'BEGE'
].sort((a, b) => b.length - a.length);
export const normalizeProductText = (value: string) => value.replace(/\s+/g, ' ').trim();
const normalizeForMatch = (value: string) => normalizeProductText(value)
.normalize('NFD')
.replace(/\p{Diacritic}/gu, '')
.toUpperCase();
const escapeRegex = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const toFlexiblePattern = (value: string) => escapeRegex(value).replace(/\s+/g, '\\s+');
const findLeadingColor = (value: string) => {
const normalizedValue = normalizeForMatch(value);
return knownColors.find(color => normalizedValue === color || normalizedValue.startsWith(`${color} `)) || '';
};
const findTrailingColor = (value: string) => {
const normalizedValue = normalizeForMatch(value);
return knownColors.find(color => normalizedValue === color || normalizedValue.endsWith(` ${color}`) || normalizedValue.endsWith(`- ${color}`)) || '';
};
export const encodeProductGroupKey = (value: string) => {
const normalizedValue = normalizeProductText(value);
const bytes = new TextEncoder().encode(normalizedValue);
@@ -34,16 +80,51 @@ export const parseProductName = (name: string) => {
const explicitSizeMatch = cleanName.match(/\bTAMANHO\s*-?\s*([A-Z0-9]+)\b/i);
const trailingTokenMatch = cleanName.match(/(?:\s+-\s+|\s)([A-Z0-9]+)$/i);
const trailingToken = trailingTokenMatch?.[1]?.toUpperCase() || '';
const size = (explicitSizeMatch?.[1] || (sizeSet.has(trailingToken) ? trailingToken : '')).toUpperCase();
let size = (explicitSizeMatch?.[1] || (sizeSet.has(trailingToken) ? trailingToken : '')).toUpperCase();
const colorMatch = cleanName.match(/\bCOR\s+(.+?)(?:\s+TAMANHO|\s+-\s+[A-Z0-9]+$|$)/i);
const color = normalizeProductText(colorMatch?.[1] || '');
let color = normalizeProductText(findLeadingColor(colorMatch?.[1] || '') || colorMatch?.[1] || '');
if (!color && size) {
const nameBeforeSize = cleanName.replace(new RegExp(`(?:\\s+-\\s+|\\s)${escapeRegex(size)}$`, 'i'), '');
color = findTrailingColor(nameBeforeSize);
}
if (!color) {
const trailingColor = findTrailingColor(cleanName);
if (trailingColor) {
const normalizedName = normalizeForMatch(cleanName);
const normalizedPrefix = normalizeProductText(normalizedName.replace(new RegExp(`${escapeRegex(trailingColor)}$`), ''));
const tokenBeforeColor = normalizedPrefix.match(/([A-Z0-9]+)$/)?.[1] || '';
if (sizeSet.has(tokenBeforeColor)) {
color = trailingColor;
size = tokenBeforeColor;
}
}
}
let baseName = cleanName
.replace(/\bCOR\s+.+?(?:\s+TAMANHO\s*-?\s*[A-Z0-9]+|\s+-\s+[A-Z0-9]+$|$)/i, '')
.replace(/\bTAMANHO\s*-?\s*[A-Z0-9]+\b/i, '')
.replace(/\s+-\s*[A-Z0-9]+$/i, '');
if (color && size) {
const colorPattern = toFlexiblePattern(color);
const sizePattern = escapeRegex(size);
baseName = baseName
.replace(new RegExp(`\\s+-\\s*${colorPattern}\\s+${sizePattern}$`, 'i'), '')
.replace(new RegExp(`\\s+${colorPattern}\\s+-\\s*${sizePattern}$`, 'i'), '')
.replace(new RegExp(`\\s+${sizePattern}\\s+${colorPattern}$`, 'i'), '')
.replace(new RegExp(`\\s+${colorPattern}\\s+${sizePattern}$`, 'i'), '');
}
if (color) {
const colorPattern = toFlexiblePattern(color);
baseName = baseName
.replace(new RegExp(`\\s+-\\s*${colorPattern}$`, 'i'), '')
.replace(new RegExp(`\\s+${colorPattern}$`, 'i'), '');
}
baseName = normalizeProductText(baseName.replace(/\s+-\s*$/g, ''));
return {

View File

@@ -34,6 +34,26 @@ export interface ProductionOrderMarker {
color?: string | null;
}
export interface ProductionOrderComponent {
id: number;
componentTinyId: string;
componentSku: string;
componentName: string;
quantityPerUnit: number;
totalQuantity: number;
unit: string;
}
export interface ProductionOrderStep {
id: number;
stepNumber: number | null;
name: string;
startDate: string | null;
endDate: string | null;
status: string;
color: string;
}
export interface ProductionOrderItem {
id: number;
tinyId: string;
@@ -48,7 +68,16 @@ export interface ProductionOrderItem {
quantity: number;
unit: string;
integrationStatus: string;
notes: string;
supplier: string;
lotCode: string;
rollQuantity: number | null;
fabricKg: number | null;
ribKg: number | null;
yieldPiecesPerKg: number | null;
markers: ProductionOrderMarker[];
components: ProductionOrderComponent[];
steps: ProductionOrderStep[];
createdAt: string | null;
updatedAt: string | null;
}
@@ -67,6 +96,35 @@ export interface ProductionOrderSummary {
counts: ProductionOrderCounts;
}
export type ProductionOrderMarkerPayload = {
label: string;
color?: string | null;
};
export type ProductionOrderPayload = {
number?: string;
status?: ProductionOrderStatus;
orderReference?: string;
issueDate?: string | null;
expectedDate?: string | null;
productSku?: string;
productDescription: string;
quantity: number;
unit?: string;
integrationStatus?: string;
markers?: ProductionOrderMarkerPayload[];
metadata?: Record<string, unknown>;
};
export interface CreateProductionOrdersResult {
created: ProductionOrderItem[];
skipped: Array<{
productSku?: string;
productDescription?: string;
reason: string;
}>;
}
export type CutFamilyKey = 'BLCS' | 'BLOS' | 'BLMC' | 'BLPM' | 'OUTROS';
export interface CutProductOverride {
@@ -127,6 +185,10 @@ export interface ConsumptionReference {
efficiencyPercent: number | null;
ribGPerPiece: number | null;
materialCostPerKg: number | null;
consumptionQuantity: number | null;
consumptionUnit: string;
source: string;
lastProductionOrderId: number | null;
createdAt: string;
updatedAt: string;
}
@@ -168,6 +230,8 @@ export type ConsumptionReferencePayload = {
efficiencyPercent?: number | string | null;
ribGPerPiece?: number | string | null;
materialCostPerKg?: number | string | null;
consumptionQuantity?: number | string | null;
consumptionUnit?: string | null;
};
export type SupplyReceiptStatus = 'pending' | 'approved';
@@ -246,6 +310,8 @@ export interface SupplyPurchaseNeed {
quantitySold: number;
stockQuantity: number;
yieldPerKg?: number;
consumptionQuantity?: number;
consumptionUnit?: string;
}>;
}
@@ -409,6 +475,48 @@ export interface ProductDetailsAnalytics {
}>;
}
export interface ProductCompositionComponent {
id: number;
componentTinyId: string;
componentSku: string;
componentName: string;
quantityPerUnit: number;
unit: string;
productId: string | null;
}
export interface ProductComposition {
id: number;
source: string;
externalSourceId: string;
finishedProductSku: string;
finishedProductDescription: string;
finishedProductUnit: string;
finishedTinyProductId: string;
sourceMetadata: Record<string, unknown>;
lastSyncedAt: string | null;
components: ProductCompositionComponent[];
}
export interface ProductCompositionImportSummary {
imported: number;
failed: number;
componentCount: number;
referenceCount: number;
skippedReferenceCount: number;
issues: Array<{
type: string;
productSku?: string;
product?: string;
component?: string;
componentSku?: string;
tinyProductId?: string;
unit?: string;
count?: number;
message?: string;
}>;
}
export interface ClientAnalyticsItem {
customerKey: string;
clientToken: string;
@@ -420,6 +528,25 @@ export interface ClientAnalyticsItem {
lastPurchaseDate: string;
}
export interface SellerAnalyticsItem {
id: string;
name: string;
revenue: number;
quantitySold: number;
orderCount: number;
customerCount: number;
lastOrderDate: string | null;
averageTicket: number;
}
export interface SellerDetailsAnalytics {
seller: SellerAnalyticsItem;
chartData: Array<{ date: string; revenue: number; quantitySold: number; orderCount: number }>;
topProducts: ProductAnalyticsItem[];
topClients: ClientAnalyticsItem[];
marketplaces: Array<{ name: string; revenue: number; orderCount: number }>;
}
export interface ClientMetadataFilters {
marketplace: string;
canal_venda: string;

9
src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1,9 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_URL?: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}