Compare commits

...

38 Commits

Author SHA1 Message Date
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
38 changed files with 3890 additions and 413 deletions

View File

@@ -20,6 +20,15 @@ ADMIN_EMAIL=admin@admin.com
ADMIN_PASSWORD=admin123 ADMIN_PASSWORD=admin123
JWT_SECRET=super_secret_jwt_key_123 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) --- # --- Frontend Configuration (Optional) ---
# If you need to override the API URL for the frontend # If you need to override the API URL for the frontend
# VITE_API_URL=/api # VITE_API_URL=/api

View File

@@ -112,8 +112,17 @@ N8N_WHATSAPP_TRIGGER_URL
ADMIN_EMAIL ADMIN_EMAIL
ADMIN_PASSWORD ADMIN_PASSWORD
JWT_SECRET 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 ## Validation
```bash ```bash

View File

@@ -1,5 +1,34 @@
require('dotenv').config(); 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 = { module.exports = {
PORT: process.env.PORT || 3004, PORT: process.env.PORT || 3004,
API_KEY: process.env.API_KEY || 'nexstar_secret_key_123', API_KEY: process.env.API_KEY || 'nexstar_secret_key_123',
@@ -7,5 +36,7 @@ module.exports = {
ADMIN_PASSWORD: process.env.ADMIN_PASSWORD || 'admin123', ADMIN_PASSWORD: process.env.ADMIN_PASSWORD || 'admin123',
JWT_SECRET: process.env.JWT_SECRET || 'super_secret_jwt_key_123', JWT_SECRET: process.env.JWT_SECRET || 'super_secret_jwt_key_123',
DATABASE_URL: process.env.DATABASE_URL || 'postgres://graphuser:graphpassword@localhost:5432/graphdb', 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 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 () => { const initDB = async () => {
try { try {
await pool.query(`SET TIME ZONE 'America/Sao_Paulo';`); await pool.query(`SET TIME ZONE 'America/Sao_Paulo';`);
@@ -105,6 +128,13 @@ const initDB = async () => {
quantity NUMERIC(14, 4) DEFAULT 0, quantity NUMERIC(14, 4) DEFAULT 0,
unit VARCHAR(20) DEFAULT 'UN', unit VARCHAR(20) DEFAULT 'UN',
integration_status VARCHAR(100), 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, tiny_payload JSONB,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_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(` await pool.query(`
CREATE TABLE IF NOT EXISTS cutting_family_rules ( CREATE TABLE IF NOT EXISTS cutting_family_rules (
family_key VARCHAR(20) PRIMARY KEY, family_key VARCHAR(20) PRIMARY KEY,
@@ -185,6 +279,10 @@ const initDB = async () => {
efficiency_percent NUMERIC(7, 3), efficiency_percent NUMERIC(7, 3),
rib_g_per_piece NUMERIC(14, 4), rib_g_per_piece NUMERIC(14, 4),
material_cost_per_kg 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, created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
); );
@@ -254,6 +352,33 @@ const initDB = async () => {
await pool.query(` await pool.query(`
ALTER TABLE production_orders 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 TYPE TIMESTAMPTZ USING created_at AT TIME ZONE 'America/Sao_Paulo',
ALTER COLUMN created_at SET DEFAULT CURRENT_TIMESTAMP, 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 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; ALTER COLUMN updated_at SET DEFAULT CURRENT_TIMESTAMP;
`).catch(() => {}); `).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(` await pool.query(`
ALTER TABLE consumption_references ALTER TABLE consumption_references
ALTER COLUMN created_at TYPE TIMESTAMPTZ USING created_at AT TIME ZONE 'America/Sao_Paulo', 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; ALTER COLUMN updated_at SET DEFAULT CURRENT_TIMESTAMP;
`).catch(() => {}); `).catch(() => {});
await seedDefaultCatalogCategories();
await pool.query(` await pool.query(`
ALTER TABLE supply_receipts ALTER TABLE supply_receipts
ALTER COLUMN created_at TYPE TIMESTAMPTZ USING created_at AT TIME ZONE 'America/Sao_Paulo', 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_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_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_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_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_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_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_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 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_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_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);`); await pool.query(`CREATE INDEX IF NOT EXISTS idx_supply_stock_lots_status ON supply_stock_lots (status);`);

View File

@@ -10,6 +10,7 @@ const {
getProductDetailsAnalytics, getProductDetailsAnalytics,
getRfmAnalytics getRfmAnalytics
} = require('../services/analyticsService'); } = require('../services/analyticsService');
const { getProductComposition, listProductCompositions } = require('../services/productionOrderService');
const router = express.Router(); const router = express.Router();
@@ -58,6 +59,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) => { router.get('/analytics/clients', verifyToken, async (req, res) => {
try { try {
res.json(await getClientAnalytics(getClientAnalyticsFilters(req.query))); res.json(await getClientAnalytics(getClientAnalyticsFilters(req.query)));

View File

@@ -1,12 +1,59 @@
const express = require('express'); const express = require('express');
const { login } = require('../auth'); const { login } = require('../auth');
const { TURNSTILE_SECRET, TURNSTILE_SITE_KEY } = require('../config');
const router = express.Router(); 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 verifyCaptcha = async (captchaToken, remoteIp) => {
const { email, password } = req.body; if (!TURNSTILE_SECRET) return true;
if (!captchaToken || typeof captchaToken !== 'string') return false;
try { 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); const authResult = await login(email, password);
if (!authResult) { if (!authResult) {

View File

@@ -1,14 +1,24 @@
const express = require('express'); const express = require('express');
const { verifyToken } = require('../auth'); const { authenticateAPIKey, verifyToken } = require('../auth');
const { const {
getCampaignPreview, getCampaignPreview,
getCampaignQueueSummary, getCampaignQueueSummary,
getTopClientsForCampaign,
processPendingStockCampaigns, processPendingStockCampaigns,
retryCampaignItems retryCampaignItems
} = require('../services/campaignService'); } = require('../services/campaignService');
const router = express.Router(); 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) => { router.get('/campaigns', verifyToken, async (req, res) => {
try { try {
res.json(await getCampaignQueueSummary()); 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) => { router.post('/campaigns/process', verifyToken, async (req, res) => {
try { try {
res.json(await processPendingStockCampaigns()); res.json(await processPendingStockCampaigns());

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 express = require('express');
const { verifyToken } = require('../auth'); const { authenticateAPIKey, verifyToken } = require('../auth');
const { listProductionOrders } = require('../services/productionOrderService'); const { createProductionOrders, listProductionOrders, updateProductionOrderStatus, upsertTinyProductionOrderDetail } = require('../services/productionOrderService');
const router = express.Router(); const router = express.Router();
@@ -13,4 +13,32 @@ 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.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; module.exports = router;

View File

@@ -12,6 +12,7 @@ const productionOrderRoutes = require('./routes/productionOrderRoutes');
const cuttingSettingsRoutes = require('./routes/cuttingSettingsRoutes'); const cuttingSettingsRoutes = require('./routes/cuttingSettingsRoutes');
const catalogRoutes = require('./routes/catalogRoutes'); const catalogRoutes = require('./routes/catalogRoutes');
const supplyRoutes = require('./routes/supplyRoutes'); const supplyRoutes = require('./routes/supplyRoutes');
const databaseDiagnosticRoutes = require('./routes/databaseDiagnosticRoutes');
const createApp = () => { const createApp = () => {
const app = express(); const app = express();
@@ -27,6 +28,7 @@ const createApp = () => {
app.use('/api', cuttingSettingsRoutes); app.use('/api', cuttingSettingsRoutes);
app.use('/api', catalogRoutes); app.use('/api', catalogRoutes);
app.use('/api', supplyRoutes); app.use('/api', supplyRoutes);
app.use('/api', databaseDiagnosticRoutes);
app.use('/api', analyticsRoutes); app.use('/api', analyticsRoutes);
app.use('/api', userRoutes); app.use('/api', userRoutes);
app.use('/api/internal', internalRoutes); app.use('/api/internal', internalRoutes);

View File

@@ -6,6 +6,26 @@ const applyProductDisplayAlias = (name) => {
.replace(/^BASE LISA MOLETOM CANGURU\b/i, 'MOLETOM CANGURU PREMIUM'); .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) => { const formatProductNameForDisplay = (name) => {
return applyProductDisplayAlias(name) return applyProductDisplayAlias(name)
.toLocaleLowerCase('pt-BR') .toLocaleLowerCase('pt-BR')
@@ -103,5 +123,6 @@ module.exports = {
formatProductList, formatProductList,
groupCampaignRows, groupCampaignRows,
groupCampaignRowsByBaseProduct, groupCampaignRowsByBaseProduct,
isCampaignEligibleProductName,
mapCampaignProducts mapCampaignProducts
}; };

View File

@@ -5,14 +5,118 @@ const {
formatProductList, formatProductList,
groupCampaignRows, groupCampaignRows,
groupCampaignRowsByBaseProduct, groupCampaignRowsByBaseProduct,
isCampaignEligibleProductName,
mapCampaignProducts mapCampaignProducts
} = require('./campaignFormatter'); } = require('./campaignFormatter');
const TOP_BUYERS_LIMIT = 100; 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 MAX_CAMPAIGN_ATTEMPTS = 3;
const CAMPAIGN_DELTA_THRESHOLD = 100; 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) => { const enqueueStockCampaignItem = async (client, item) => {
if (!isCampaignEligibleProductName(item.baseProductName || item.nome)) {
return false;
}
const query = ` const query = `
INSERT INTO stock_campaign_queue ( INSERT INTO stock_campaign_queue (
base_product_name, produto_id, nome, saldo, delta_estoque base_product_name, produto_id, nome, saldo, delta_estoque
@@ -26,6 +130,8 @@ const enqueueStockCampaignItem = async (client, item) => {
item.saldo, item.saldo,
item.deltaEstoque item.deltaEstoque
]); ]);
return true;
}; };
const getTopBuyersAllTime = async () => { const getTopBuyersAllTime = async () => {
@@ -46,6 +152,63 @@ const getTopBuyersAllTime = async () => {
return result.rows; 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 claimReadyCampaignItems = async () => {
const client = await pool.connect(); const client = await pool.connect();
@@ -117,7 +280,8 @@ const getCampaignQueueRows = async () => {
}; };
const getCampaignQueueSummary = async () => { const getCampaignQueueSummary = async () => {
const rows = await getCampaignQueueRows(); const rows = (await getCampaignQueueRows())
.filter(row => isCampaignEligibleProductName(row.base_product_name || row.nome));
return { return {
threshold: CAMPAIGN_DELTA_THRESHOLD, threshold: CAMPAIGN_DELTA_THRESHOLD,
maxAttempts: MAX_CAMPAIGN_ATTEMPTS, maxAttempts: MAX_CAMPAIGN_ATTEMPTS,
@@ -134,7 +298,8 @@ const getCampaignPreview = async () => {
AND attempts < $1 AND attempts < $1
ORDER BY created_at ASC, id ASC; ORDER BY created_at ASC, id ASC;
`, [MAX_CAMPAIGN_ATTEMPTS]); `, [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 readyGroups = {};
const belowThresholdGroups = {}; const belowThresholdGroups = {};
@@ -205,6 +370,15 @@ const updateCampaignItemsStatus = async (ids, status, errorMessage = null) => {
`, [status, errorMessage, ids]); `, [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 sendWhatsappCampaign = async (products, customers) => {
const response = await fetch(N8N_WHATSAPP_TRIGGER_URL, { const response = await fetch(N8N_WHATSAPP_TRIGGER_URL, {
method: 'POST', method: 'POST',
@@ -231,14 +405,21 @@ const processPendingStockCampaigns = async () => {
return summary; 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 products = mapCampaignProducts(groups);
const ids = products.flatMap(product => product.itemIds); const ids = products.flatMap(product => product.itemIds);
const customers = await getTopBuyersAllTime(); const customers = await getTopBuyersAllTime();
if (!products.length) {
return summary;
}
if (!customers.length) { if (!customers.length) {
await updateCampaignItemsStatus(ids, 'skipped', 'No customers with valid phone numbers found.'); await updateCampaignItemsStatus(ids, 'skipped', 'No customers with valid phone numbers found.');
summary.skippedGroups = products.length; summary.skippedGroups += products.length;
return summary; return summary;
} }
@@ -260,6 +441,7 @@ module.exports = {
enqueueStockCampaignItem, enqueueStockCampaignItem,
getCampaignPreview, getCampaignPreview,
getCampaignQueueSummary, getCampaignQueueSummary,
getTopClientsForCampaign,
retryCampaignItems, retryCampaignItems,
processPendingStockCampaigns processPendingStockCampaigns
}; };

View File

@@ -73,6 +73,10 @@ const mapConsumptionReference = (row) => ({
efficiencyPercent: row.efficiency_percent === null ? null : Number(row.efficiency_percent), efficiencyPercent: row.efficiency_percent === null ? null : Number(row.efficiency_percent),
ribGPerPiece: row.rib_g_per_piece === null ? null : Number(row.rib_g_per_piece), 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), 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, createdAt: row.created_at,
updatedAt: row.updated_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.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.color, r.general_yield, r.size_yields, r.size_areas,
r.gramature, r.efficiency_percent, r.rib_g_per_piece, 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 FROM consumption_references r
JOIN catalog_products p ON p.id = r.product_id JOIN catalog_products p ON p.id = r.product_id
LEFT JOIN catalog_products m ON m.id = r.material_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 const calculatedYield = Object.values(sizeYields).length
? Object.values(sizeYields).reduce((total, value) => total + value, 0) / Object.values(sizeYields).length ? Object.values(sizeYields).reduce((total, value) => total + value, 0) / Object.values(sizeYields).length
: null; : null;
const consumptionQuantity = normalizeNumber(payload.consumptionQuantity);
if (!generalYield && !calculatedYield) { if (!generalYield && !calculatedYield && !consumptionQuantity) {
const error = new Error('Informe o rendimento geral ou por tamanho.'); const error = new Error('Informe o rendimento ou consumo por peça.');
error.statusCode = 400; error.statusCode = 400;
throw error; throw error;
} }
@@ -219,9 +225,9 @@ const createConsumptionReference = async (payload) => {
INSERT INTO consumption_references ( INSERT INTO consumption_references (
product_id, material_product_id, color, general_yield, size_yields, product_id, material_product_id, color, general_yield, size_yields,
size_areas, gramature, efficiency_percent, rib_g_per_piece, 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 RETURNING id
`, [ `, [
productId, productId,
@@ -233,7 +239,9 @@ const createConsumptionReference = async (payload) => {
normalizeNumber(payload.gramature), normalizeNumber(payload.gramature),
normalizeNumber(payload.efficiencyPercent), normalizeNumber(payload.efficiencyPercent),
normalizeNumber(payload.ribGPerPiece), normalizeNumber(payload.ribGPerPiece),
normalizeNumber(payload.materialCostPerKg) normalizeNumber(payload.materialCostPerKg),
consumptionQuantity,
normalizeText(payload.consumptionUnit) || null
]); ]);
const references = await listConsumptionReferences(); const references = await listConsumptionReferences();

View File

@@ -0,0 +1,177 @@
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']
},
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 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) => ({ const mapReceipt = (row) => ({
id: row.id, id: row.id,
category: row.category, category: row.category,
@@ -249,7 +256,10 @@ const listConsumptionReferenceRows = async () => {
m.name AS material_name, m.name AS material_name,
r.color, r.color,
r.general_yield, r.general_yield,
r.size_yields r.size_yields,
r.consumption_quantity,
r.consumption_unit,
r.source
FROM consumption_references r FROM consumption_references r
JOIN catalog_products p ON p.id = r.product_id JOIN catalog_products p ON p.id = r.product_id
LEFT JOIN catalog_products m ON m.id = r.material_product_id LEFT JOIN catalog_products m ON m.id = r.material_product_id
@@ -296,7 +306,12 @@ const buildProjectPurchaseNeeds = async (lots, receipts) => {
listProjectDemandRows(), listProjectDemandRows(),
listConsumptionReferenceRows() listConsumptionReferenceRows()
]); ]);
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 needsByMaterial = new Map();
demandRows.forEach(row => { demandRows.forEach(row => {
@@ -309,8 +324,8 @@ const buildProjectPurchaseNeeds = async (lots, receipts) => {
const suggestedQuantity = Math.max(Math.ceil(projectedDemand - stockQuantity), 0); const suggestedQuantity = Math.max(Math.ceil(projectedDemand - stockQuantity), 0);
if (suggestedQuantity <= 0) return; if (suggestedQuantity <= 0) return;
const reference = referencesBySku.get(productId); const references = referencesBySku.get(productId) || [];
if (!reference) { if (!references.length) {
mergeNeedLine(needsByMaterial, `missing:${productId}`, { mergeNeedLine(needsByMaterial, `missing:${productId}`, {
material: `Cadastrar consumo: ${normalizeText(row.product_name) || productId}`, material: `Cadastrar consumo: ${normalizeText(row.product_name) || productId}`,
plannedKg: suggestedQuantity, plannedKg: suggestedQuantity,
@@ -329,9 +344,35 @@ const buildProjectPurchaseNeeds = async (lots, receipts) => {
return; 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); const yieldPerKg = getReferenceYield(reference);
if (!yieldPerKg) { 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}`, material: `Cadastrar rendimento: ${normalizeText(row.product_name) || productId}`,
plannedKg: suggestedQuantity, plannedKg: suggestedQuantity,
priority: 'Crítico', priority: 'Crítico',
@@ -350,13 +391,12 @@ const buildProjectPurchaseNeeds = async (lots, receipts) => {
return; return;
} }
const materialName = normalizeText(reference.material_name) || normalizeText(reference.material_sku) || 'Material sem cadastro'; mergeNeedLine(needsByMaterial, `kg:${normalizeKey(materialName)}`, {
mergeNeedLine(needsByMaterial, normalizeKey(materialName), {
material: materialName, material: materialName,
plannedKg: suggestedQuantity / yieldPerKg, plannedKg: suggestedQuantity / yieldPerKg,
priority: 'Atenção', priority: 'Atenção',
unit: 'kg', unit: 'kg',
source: 'project_demand', source: reference.source || 'project_demand',
color: reference.color, color: reference.color,
product: { product: {
productId, productId,
@@ -368,17 +408,17 @@ const buildProjectPurchaseNeeds = async (lots, receipts) => {
} }
}); });
}); });
});
lots.forEach(lot => { lots.forEach(lot => {
if (lot.unit !== 'kg') return; const need = needsByMaterial.get(`${normalizeUnit(lot.unit)}:${normalizeKey(lot.product)}`);
const need = needsByMaterial.get(normalizeKey(lot.product)); if (need) need.stockKg += lot.quantity;
if (need && need.unit === 'kg') need.stockKg += lot.quantity;
}); });
receipts.forEach(receipt => { receipts.forEach(receipt => {
if (receipt.status !== 'pending' || receipt.unit !== 'kg') return; if (receipt.status !== 'pending') return;
const need = needsByMaterial.get(normalizeKey(receipt.product)); const need = needsByMaterial.get(`${normalizeUnit(receipt.unit)}:${normalizeKey(receipt.product)}`);
if (need && need.unit === 'kg') need.pendingKg += receipt.quantity; if (need) need.pendingKg += receipt.quantity;
}); });
return Array.from(needsByMaterial.values()).map(need => { return Array.from(needsByMaterial.values()).map(need => {

View File

@@ -6,6 +6,7 @@ const {
formatProductNameForDisplay, formatProductNameForDisplay,
groupCampaignRows, groupCampaignRows,
groupCampaignRowsByBaseProduct, groupCampaignRowsByBaseProduct,
isCampaignEligibleProductName,
mapCampaignProducts mapCampaignProducts
} = require('../services/campaignFormatter'); } = 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', () => { test('mapCampaignProducts accumulates split deltas by base product', () => {
const groups = groupCampaignRowsByBaseProduct([ const groups = groupCampaignRowsByBaseProduct([
row({ id: 1, delta_estoque: 10, produto_id: 'SKU-P', nome: 'Produto Split TAMANHO - P' }), 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,275 @@
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 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,
compositions: [],
compositionComponents: [],
productionOrders: []
};
const query = async (sql, params = []) => {
const normalizedSql = String(sql).replace(/\s+/g, ' ').trim();
if (/^(BEGIN|COMMIT|ROLLBACK);?$/.test(normalizedSql)) 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(result.composition.finishedTinyProductId, '976058813');
});
});
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} - ADMIN_PASSWORD=${ADMIN_PASSWORD:-admin123}
- JWT_SECRET=${JWT_SECRET:-super_secret_jwt_key_123} - JWT_SECRET=${JWT_SECRET:-super_secret_jwt_key_123}
- N8N_WHATSAPP_TRIGGER_URL=${N8N_WHATSAPP_TRIGGER_URL:-http://localhost:5678/webhook/whatsapp} - 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: depends_on:
- db - db
restart: unless-stopped restart: unless-stopped

View File

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

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, ProductDetailsAnalytics, ProductionOrderItem, ProductionOrderPayload, ProductionOrderStatus, ProductionOrderSummary, RfmAnalytics, StockData, SupplyFabricPlan, SupplyFabricPlanPayload, SupplyInventoryAdjustmentPayload, SupplyLot, SupplyProductionExitPayload, SupplyPurchaseNeed, SupplyReceipt, SupplyReceiptPayload, SupplySummary } from './types';
import { formatDateParam } from './dateRanges'; import { formatDateParam } from './dateRanges';
const API_URL = import.meta.env.VITE_API_URL || '/api'; const API_URL = import.meta.env.VITE_API_URL || '/api';
@@ -55,27 +55,42 @@ const buildDateRangeParams = (dateRange: DateRange) => new URLSearchParams({
end: formatDateParam(dateRange.end) 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 { try {
const response = await fetch(`${API_URL}/login`, { const response = await fetch(`${API_URL}/login`, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
body: JSON.stringify({ email, password }), body: JSON.stringify({ email, password, captchaToken }),
}); });
if (response.ok) { if (response.ok) {
const data = await response.json() as { token?: string; user?: AuthUser }; 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_token', data.token);
localStorage.setItem('auth_user', JSON.stringify(data.user)); 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) { } catch (error) {
console.error('Login failed', 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; 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 ( export const fetchProductionOrders = async (
dateRange: DateRange, dateRange: DateRange,
filters?: { search?: string }, filters?: { search?: string },
@@ -183,6 +215,30 @@ export const fetchProductionOrders = async (
}, options); }, 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> => { export const fetchCuttingSettings = async (): Promise<CuttingSettings> => {
try { try {
const response = await authFetch('/cutting-settings'); const response = await authFetch('/cutting-settings');
@@ -483,6 +539,42 @@ 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 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>) => { const appendClientMetadataFilterParams = (params: URLSearchParams, filters?: Partial<ClientMetadataFilters>) => {
if (!filters) return; if (!filters) return;
if (filters.marketplace) params.set('marketplace', filters.marketplace); if (filters.marketplace) params.set('marketplace', filters.marketplace);

View File

@@ -7,7 +7,7 @@ import ProductColorBadge from '../components/ProductColorBadge';
import RefreshStatus from '../components/RefreshStatus'; import RefreshStatus from '../components/RefreshStatus';
import { buildCuttingSkuConfigPath } from '../catalogLinks'; import { buildCuttingSkuConfigPath } from '../catalogLinks';
import { CUT_FAMILY_RULES, buildCutPlan, buildOpenProductionByProductId, type CutFamilyKey, type CutIssue, type CutPlanSkuRow, type CutProductOverride } from '../analytics/cutting'; 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 { createProductionOrders, exportToCSV, fetchCuttingSettings, fetchProductAnalytics, fetchProductionOrders, saveCuttingSettings } from '../dataService';
import type { CuttingSettings, DateRange, ProductAnalyticsItem, ProductionOrderItem } from '../types'; import type { CuttingSettings, DateRange, ProductAnalyticsItem, ProductionOrderItem } from '../types';
type CutFilter = 'need' | 'all' | 'issues' | 'covered'; type CutFilter = 'need' | 'all' | 'issues' | 'covered';
@@ -16,6 +16,7 @@ type SaveStatus = 'idle' | 'saving' | 'saved' | 'error';
type CutProductIssue = Exclude<CutIssue, 'missing_yield_rule'>; type CutProductIssue = Exclude<CutIssue, 'missing_yield_rule'>;
type CorrectionIssueFilter = CutProductIssue | 'all'; type CorrectionIssueFilter = CutProductIssue | 'all';
type SettingsSection = 'rules' | 'corrections'; type SettingsSection = 'rules' | 'corrections';
type CuttingView = 'plan' | 'families' | 'issues';
const SETTINGS_STORAGE_KEY = 'nexstar_cutting_settings'; const SETTINGS_STORAGE_KEY = 'nexstar_cutting_settings';
const coverageTargetOptions = [7, 15, 30, 60]; const coverageTargetOptions = [7, 15, 30, 60];
@@ -65,6 +66,13 @@ const formatNumber = (value: number, maximumFractionDigits = 0) => (
new Intl.NumberFormat('pt-BR', { maximumFractionDigits }).format(value) new Intl.NumberFormat('pt-BR', { maximumFractionDigits }).format(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) => { const formatDays = (value: number | null) => {
if (value === null) return '-'; if (value === null) return '-';
if (value > 999) return '999+ dias'; if (value > 999) return '999+ dias';
@@ -135,6 +143,10 @@ const Cutting = () => {
const [correctionPage, setCorrectionPage] = useState(1); const [correctionPage, setCorrectionPage] = useState(1);
const [currentPage, setCurrentPage] = useState(1); const [currentPage, setCurrentPage] = useState(1);
const [itemsPerPage, setItemsPerPage] = useState(10); 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 cutConfigSection = searchParams.get('config');
const targetCorrectionSku = (searchParams.get('sku') || '').trim(); const targetCorrectionSku = (searchParams.get('sku') || '').trim();
@@ -241,6 +253,14 @@ const Cutting = () => {
}); });
}, [cutFilter, cutPlan.rows, familyFilter, searchTerm, sortBy]); }, [cutFilter, cutPlan.rows, familyFilter, searchTerm, sortBy]);
const rowsAvailableForOrder = useMemo(() => (
filteredRows.filter(row => row.suggestedCutQuantity > 0)
), [filteredRows]);
const orderPreviewTotalUnits = useMemo(() => (
rowsAvailableForOrder.reduce((total, row) => total + row.suggestedCutQuantity, 0)
), [rowsAvailableForOrder]);
const totalPages = Math.ceil(filteredRows.length / itemsPerPage); const totalPages = Math.ceil(filteredRows.length / itemsPerPage);
const safeCurrentPage = Math.min(currentPage, totalPages || 1); const safeCurrentPage = Math.min(currentPage, totalPages || 1);
const startIndex = (safeCurrentPage - 1) * itemsPerPage; const startIndex = (safeCurrentPage - 1) * itemsPerPage;
@@ -289,12 +309,16 @@ const Cutting = () => {
}, [correctionItemsPerPage, correctionRows, cutConfigSection, targetCorrectionSku]); }, [correctionItemsPerPage, correctionRows, cutConfigSection, targetCorrectionSku]);
useEffect(() => { useEffect(() => {
if (!isSettingsOpen) return undefined; if (!isSettingsOpen && !isOrderPreviewOpen) return undefined;
const closeOnEscape = (event: KeyboardEvent) => { const closeOnEscape = (event: KeyboardEvent) => {
if (event.key === 'Escape') { if (event.key === 'Escape') {
if (isOrderPreviewOpen) {
setIsOrderPreviewOpen(false);
} else {
setIsSettingsOpen(false); setIsSettingsOpen(false);
} }
}
}; };
const previousOverflow = document.body.style.overflow; const previousOverflow = document.body.style.overflow;
@@ -304,7 +328,7 @@ const Cutting = () => {
document.body.style.overflow = previousOverflow; document.body.style.overflow = previousOverflow;
document.removeEventListener('keydown', closeOnEscape); document.removeEventListener('keydown', closeOnEscape);
}; };
}, [isSettingsOpen]); }, [isOrderPreviewOpen, isSettingsOpen]);
const updateFamilyYield = (familyKey: CutFamilyKey, value: string) => { const updateFamilyYield = (familyKey: CutFamilyKey, value: string) => {
const parsedValue = Number(value); const parsedValue = Number(value);
@@ -400,6 +424,71 @@ const Cutting = () => {
})), `plano_corte_${new Date().toISOString().split('T')[0]}.csv`); })), `plano_corte_${new Date().toISOString().split('T')[0]}.csv`);
}; };
const openProductionOrderPreview = () => {
if (!rowsAvailableForOrder.length) {
setGenerationMessage('Não há necessidade de corte no filtro atual.');
return;
}
setGenerationMessage('');
setIsOrderPreviewOpen(true);
};
const generateProductionOrders = async () => {
if (!rowsAvailableForOrder.length) {
setGenerationMessage('Não há necessidade de corte 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 pendente', color: '#facc15' },
...(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) => { const renderIssueBadge = (row: CutPlanSkuRow) => {
if (!row.issues.length) { if (!row.issues.length) {
return <span className="text-xs font-bold text-emerald-300">OK</span>; return <span className="text-xs font-bold text-emerald-300">OK</span>;
@@ -444,6 +533,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 <button
type="button" type="button"
onClick={exportRows} onClick={exportRows}
@@ -466,8 +566,136 @@ const Cutting = () => {
</div> </div>
</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} /> <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 && ( {isSettingsOpen && (
<div <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" className="fixed inset-0 z-50 flex h-dvh justify-end overflow-hidden bg-zinc-950/80 backdrop-blur-md dark:bg-black/75"
@@ -753,7 +981,59 @@ const Cutting = () => {
</div> </div>
</div> </div>
{!!issueSummaries.length && ( <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">Plano</span>
<span className="block text-xs font-semibold opacity-80">SKUs para cortar</span>
</span>
</span>
<span className="text-sm font-bold">{formatNumber(rowsAvailableForOrder.length)}</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="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 className="mb-3 flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div> <div>
@@ -776,8 +1056,21 @@ const Cutting = () => {
<Settings2 className="h-4 w-4 text-brand-primary" /> <Settings2 className="h-4 w-4 text-brand-primary" />
Corrigir Corrigir
</button> </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>
</div> </div>
{issueSummaries.length ? (
<div className="grid grid-cols-1 gap-3 md:grid-cols-4"> <div className="grid grid-cols-1 gap-3 md:grid-cols-4">
{issueSummaries.map(summary => ( {issueSummaries.map(summary => (
<div <div
@@ -792,10 +1085,16 @@ const Cutting = () => {
</div> </div>
))} ))}
</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> </div>
)} )}
{!!cutPlan.familySummaries.length && ( {activeCuttingView === 'families' && (
<div className="rounded-2xl border border-dark-border bg-dark-card p-4 shadow-sm"> <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 className="mb-3 flex items-center justify-between gap-3">
<div> <div>
@@ -804,6 +1103,7 @@ const Cutting = () => {
</div> </div>
<Scissors className="h-5 w-5 text-dark-muted" /> <Scissors className="h-5 w-5 text-dark-muted" />
</div> </div>
{cutPlan.familySummaries.length ? (
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 2xl:grid-cols-5"> <div className="grid grid-cols-1 gap-3 md:grid-cols-2 2xl:grid-cols-5">
{cutPlan.familySummaries.map(summary => ( {cutPlan.familySummaries.map(summary => (
<div <div
@@ -826,9 +1126,17 @@ const Cutting = () => {
</div> </div>
))} ))}
</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> </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="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"> <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" /> <Search className="absolute left-3 top-1/2 h-5 w-5 -translate-y-1/2 text-zinc-400 dark:text-dark-muted" />
@@ -1025,6 +1333,8 @@ const Cutting = () => {
/> />
</div> </div>
)} )}
</>
)}
</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 { useNavigate } from 'react-router-dom';
import { Lock } from 'lucide-react'; 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 Login = () => {
const [email, setEmail] = useState(''); const [email, setEmail] = useState('');
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
const [captchaToken, setCaptchaToken] = useState('');
const [error, setError] = useState(''); const [error, setError] = useState('');
const [isLoading, setIsLoading] = useState(false); 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 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) => { const handleLogin = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (securityMisconfigured) {
setError(securityConfigurationMessage);
return;
}
if (captchaEnabled && !captchaToken) {
setError(securityRequiredMessage);
return;
}
setIsLoading(true); setIsLoading(true);
setError(''); setError('');
try { try {
const success = await login(email, password); const result = await login(email, password, captchaToken);
if (success) { if (result === 'success') {
navigate('/graph'); 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.'); 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 { } catch {
setError('Erro ao conectar ao servidor.'); setError('Erro ao conectar ao servidor.');
if (captchaEnabled) {
setCaptchaToken('');
window.turnstile?.reset(captchaWidgetIdRef.current ?? undefined);
}
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
@@ -64,14 +222,23 @@ const Login = () => {
/> />
</div> </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 <button
type="submit" 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" 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> </button>
</form> </form>
</div> </div>

View File

@@ -218,9 +218,17 @@ const PlanningIssues = () => {
<div> <div>
<h1 className="mb-2 text-2xl font-bold text-zinc-900 dark:text-dark-text">Dados Pendentes</h1> <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"> <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> </p>
</div> </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 <DateRangePicker
dateRange={dateRange} dateRange={dateRange}
onChange={(range) => { onChange={(range) => {
@@ -229,6 +237,7 @@ const PlanningIssues = () => {
}} }}
/> />
</div> </div>
</div>
<RefreshStatus isRefreshing={isRefreshing} /> <RefreshStatus isRefreshing={isRefreshing} />

View File

@@ -7,12 +7,13 @@ import DateRangePicker from '../components/DateRangePicker';
import SkuPlanningModal from '../components/SkuPlanningModal'; import SkuPlanningModal from '../components/SkuPlanningModal';
import ProductTypeBadge from '../components/ProductTypeBadge'; import ProductTypeBadge from '../components/ProductTypeBadge';
import RefreshStatus from '../components/RefreshStatus'; import RefreshStatus from '../components/RefreshStatus';
import type { CutProductOverride, CuttingSettings, DateRange, ProductDetailsAnalytics } from '../types'; import type { CutProductOverride, CuttingSettings, DateRange, ProductComposition, ProductDetailsAnalytics } from '../types';
import { fetchCuttingSettings, fetchProductDetailsAnalytics, saveCuttingSettings } from '../dataService'; import { fetchCuttingSettings, fetchProductComposition, fetchProductDetailsAnalytics, saveCuttingSettings } from '../dataService';
import { parseProductName } from '../productParsing'; import { parseProductName } from '../productParsing';
import { formatColorLabel } from '../displayFormatters'; import { formatColorLabel } from '../displayFormatters';
import { averageRecentValues, formatDateBucketLabel, formatDateBucketLongLabel, getAutoDateBucket, getMovingAverageWindow, getRangeDayCount, getDateBucketKey, type DateBucket } from '../chartUtils'; import { averageRecentValues, formatDateBucketLabel, formatDateBucketLongLabel, getAutoDateBucket, getMovingAverageWindow, getRangeDayCount, getDateBucketKey, type DateBucket } from '../chartUtils';
import { getProductTypeConfig, resolveProductType } from '../productClassification'; import { getProductTypeConfig, resolveProductType } from '../productClassification';
import { getPlanningStock } from '../planningStock';
const CHART_GRID_COLOR = 'var(--chart-grid)'; const CHART_GRID_COLOR = 'var(--chart-grid)';
const CHART_AXIS_COLOR = 'var(--chart-axis)'; const CHART_AXIS_COLOR = 'var(--chart-axis)';
@@ -130,6 +131,8 @@ const ProductDetails = () => {
setDateRange: (range: DateRange) => void setDateRange: (range: DateRange) => void
}>(); }>();
const [details, setDetails] = useState<ProductDetailsAnalytics | null>(null); 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 [isLoading, setIsLoading] = useState(true);
const [chartMetric, setChartMetric] = useState<ProductChartMetric>('quantity'); const [chartMetric, setChartMetric] = useState<ProductChartMetric>('quantity');
const [selectedProductBucket, setSelectedProductBucket] = useState<string | null>(null); const [selectedProductBucket, setSelectedProductBucket] = useState<string | null>(null);
@@ -159,17 +162,25 @@ const ProductDetails = () => {
if (!id) { if (!id) {
if (isMounted) { if (isMounted) {
setDetails(null); setDetails(null);
setComposition(null);
setIsLoading(false); setIsLoading(false);
setIsCompositionLoading(false);
} }
return; return;
} }
setIsLoading(true); setIsLoading(true);
const productDetails = await fetchProductDetailsAnalytics(id, dateRange); setIsCompositionLoading(true);
const [productDetails, productComposition] = await Promise.all([
fetchProductDetailsAnalytics(id, dateRange),
fetchProductComposition(id)
]);
if (isMounted) { if (isMounted) {
setDetails(productDetails); setDetails(productDetails);
setComposition(productComposition);
setIsLoading(false); setIsLoading(false);
setIsCompositionLoading(false);
} }
}; };
@@ -229,7 +240,7 @@ const ProductDetails = () => {
const dateBucket = isHourlyChart ? 'day' : getAutoDateBucket(dateRange); const dateBucket = isHourlyChart ? 'day' : getAutoDateBucket(dateRange);
const periodDays = getRangeDayCount(dateRange); const periodDays = getRangeDayCount(dateRange);
const dailyAverageSold = totalSold / periodDays; 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 const stockActionLabel = projectedStockDays === null
? 'Sem venda no período' ? 'Sem venda no período'
: projectedStockDays <= 7 : projectedStockDays <= 7
@@ -402,6 +413,57 @@ const ProductDetails = () => {
</div> </div>
</div> </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="bg-white dark:bg-dark-card border border-zinc-200 dark:border-dark-border rounded-2xl p-6 shadow-sm">
<div className="mb-8 flex flex-col gap-4 md:flex-row md:items-start md:justify-between"> <div className="mb-8 flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
<div> <div>

View File

@@ -14,6 +14,7 @@ import { decodeProductGroupKey, normalizeProductText, parseProductName } from '.
import { formatColorLabel } from '../displayFormatters'; import { formatColorLabel } from '../displayFormatters';
import { getProductColor } from '../productColors'; import { getProductColor } from '../productColors';
import { getDominantProductType, getProductTypeConfig, resolveProductType, type ProductTypeKey } from '../productClassification'; import { getDominantProductType, getProductTypeConfig, resolveProductType, type ProductTypeKey } from '../productClassification';
import { getPlanningStock } from '../planningStock';
import type { CuttingSettings, DateRange, ProductAnalyticsItem, ProductionOrderItem } from '../types'; import type { CuttingSettings, DateRange, ProductAnalyticsItem, ProductionOrderItem } from '../types';
type VariantRow = ProductAnalyticsItem & { type VariantRow = ProductAnalyticsItem & {
@@ -263,7 +264,7 @@ const ProductGroupDetails = () => {
const dailySales = product.quantitySold / rangeDays; const dailySales = product.quantitySold / rangeDays;
const projectedDemand = dailySales * REPLENISHMENT_TARGET_DAYS; const projectedDemand = dailySales * REPLENISHMENT_TARGET_DAYS;
const openProductionQuantity = openProductionByProductId[product.id] || 0; 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)); const suggestedReplenishment = Math.max(0, Math.ceil(projectedDemand - availableQuantity));
return { 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 { Link, useOutletContext } from 'react-router-dom';
import { ArrowLeft, CalendarDays, CheckCircle2, ClipboardList, Clock3, Download, PackageCheck, Search } from 'lucide-react'; import { ArrowLeft, CalendarDays, CheckCircle2, ClipboardList, Clock3, Download, PackageCheck, Search } from 'lucide-react';
import DateRangePicker from '../components/DateRangePicker'; import DateRangePicker from '../components/DateRangePicker';
import PaginationControls from '../components/PaginationControls'; import PaginationControls from '../components/PaginationControls';
import RefreshStatus from '../components/RefreshStatus'; 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'; import type { DateRange, ProductionOrderItem, ProductionOrderStatus, ProductionOrderSummary, SupplyLot } from '../types';
type ProductionOrderStatusTab = 'all' | 'open' | 'in_progress' | 'finished' | 'canceled'; 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' } { 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 }> = { const statusStyles: Record<string, { label: string; className: string; dotClass: string }> = {
open: { open: {
label: 'Em aberto', label: 'Em aberto',
@@ -59,6 +63,16 @@ const formatQuantity = (value: number) => (
}).format(value) }).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) => ( const getStatusStyle = (status: ProductionOrderStatus, fallbackLabel: string) => (
statusStyles[String(status)] || { statusStyles[String(status)] || {
label: fallbackLabel || 'Em aberto', label: fallbackLabel || 'Em aberto',
@@ -121,6 +135,8 @@ const ProductionOrders = () => {
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [isSupplyBusy, setIsSupplyBusy] = useState(false); const [isSupplyBusy, setIsSupplyBusy] = useState(false);
const [supplyMessage, setSupplyMessage] = useState(''); const [supplyMessage, setSupplyMessage] = useState('');
const [busyStatusOrderId, setBusyStatusOrderId] = useState<number | null>(null);
const [statusMessage, setStatusMessage] = useState('');
const [currentPage, setCurrentPage] = useState(1); const [currentPage, setCurrentPage] = useState(1);
const [itemsPerPage, setItemsPerPage] = useState(20); const [itemsPerPage, setItemsPerPage] = useState(20);
const [exitForm, setExitForm] = useState({ const [exitForm, setExitForm] = useState({
@@ -190,6 +206,23 @@ const ProductionOrders = () => {
void loadProductionOrders({ force: true }); 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 refreshSupplyLots = async () => {
const supplySummary = await fetchSupplySummary(); const supplySummary = await fetchSupplySummary();
setSupplyLots(supplySummary.lots); setSupplyLots(supplySummary.lots);
@@ -248,7 +281,7 @@ const ProductionOrders = () => {
Suprimentos Suprimentos
</Link> </Link>
<h1 className="text-2xl font-bold text-dark-text">Ordens de Produção</h1> <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> </div>
<DateRangePicker <DateRangePicker
dateRange={dateRange} dateRange={dateRange}
@@ -399,6 +432,11 @@ const ProductionOrders = () => {
<div className="overflow-hidden rounded-2xl border border-dark-border bg-dark-card shadow-sm"> <div 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="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="flex flex-col gap-4 xl:flex-row xl:items-center xl:justify-between">
<div className="relative w-full xl:max-w-xl"> <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" /> <Search className="absolute left-3 top-1/2 h-5 w-5 -translate-y-1/2 text-dark-muted" />
@@ -472,8 +510,10 @@ const ProductionOrders = () => {
<tbody className="divide-y divide-dark-border"> <tbody className="divide-y divide-dark-border">
{paginatedOrders.map((order: ProductionOrderItem) => { {paginatedOrders.map((order: ProductionOrderItem) => {
const statusStyle = getStatusStyle(order.status, order.statusLabel); const statusStyle = getStatusStyle(order.status, order.statusLabel);
const showDetails = hasProductionOrderDetails(order);
return ( 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 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">{order.orderReference || '-'}</td>
<td className="px-6 py-3 text-xs font-semibold text-dark-muted"> <td className="px-6 py-3 text-xs font-semibold text-dark-muted">
@@ -515,12 +555,91 @@ const ProductionOrders = () => {
</span> </span>
</td> </td>
<td className="px-6 py-3"> <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}`}> <label className="sr-only" htmlFor={`production-order-status-${order.id}`}>Status da OP</label>
<span className={`h-1.5 w-1.5 rounded-full ${statusStyle.dotClass}`} /> <select
{statusStyle.label} id={`production-order-status-${order.id}`}
</span> 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> </td>
</tr> </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> </tbody>
@@ -534,7 +653,7 @@ const ProductionOrders = () => {
</div> </div>
<p className="mt-4 text-sm font-bold text-dark-text">Nenhuma ordem de produção encontrada.</p> <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"> <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> </p>
</div> </div>
)} )}

View File

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

View File

@@ -1,6 +1,6 @@
import { useEffect, useMemo, useRef, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import { useSearchParams } from 'react-router-dom'; import { Link, useSearchParams } from 'react-router-dom';
import { Loader2, Package, RefreshCw, Ruler, Save, Tags, Trash2 } from 'lucide-react'; import { ClipboardList, Loader2, Package, RefreshCw, Ruler, Save, Tags, Trash2 } from 'lucide-react';
import { import {
deleteCatalogCategory, deleteCatalogCategory,
deleteCatalogProduct, deleteCatalogProduct,
@@ -92,6 +92,16 @@ const getAverageYield = (reference: ConsumptionReference) => {
return reference.generalYield; return reference.generalYield;
}; };
const getReferenceSourceLabel = (reference: ConsumptionReference) => (
reference.source === 'tiny_op' ? 'Tiny OP' : '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 Registrations = () => {
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const [catalog, setCatalog] = useState<CatalogSummary>(emptyCatalog); const [catalog, setCatalog] = useState<CatalogSummary>(emptyCatalog);
@@ -340,7 +350,7 @@ const Registrations = () => {
const tabItems: Array<{ key: RegistrationTab; label: string; icon: typeof Package; count: number }> = [ const tabItems: Array<{ key: RegistrationTab; label: string; icon: typeof Package; count: number }> = [
{ key: 'products', label: 'Produtos', icon: Package, count: catalog.products.length }, { key: 'products', label: 'Produtos', icon: Package, count: catalog.products.length },
{ key: 'categories', label: 'Categorias', icon: Tags, count: catalog.categories.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 ( return (
@@ -349,18 +359,27 @@ const Registrations = () => {
<div> <div>
<h1 className="mb-2 text-2xl font-bold text-zinc-900 dark:text-dark-text">Cadastros</h1> <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"> <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> </p>
</div> </div>
<div className="flex flex-wrap gap-2">
<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 <button
type="button" type="button"
onClick={() => void loadCatalog()} 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" /> <RefreshCw className="h-4 w-4 text-brand-primary" />
Atualizar Atualizar
</button> </button>
</div> </div>
</div>
<div className="grid grid-cols-1 gap-3 md:grid-cols-3"> <div className="grid grid-cols-1 gap-3 md:grid-cols-3">
{tabItems.map(item => { {tabItems.map(item => {
@@ -654,6 +673,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"> <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'} {Object.keys(reference.sizeYields || {}).length ? 'Por tamanho' : 'Geral'}
</span> </span>
<span className={`rounded-full border px-2 py-0.5 text-[10px] font-bold ${
reference.source === 'tiny_op'
? 'border-sky-400/30 bg-sky-400/10 text-sky-300'
: 'border-dark-border bg-dark-input text-dark-muted'
}`}>
{getReferenceSourceLabel(reference)}
</span>
</div> </div>
<h3 className="mt-1 truncate text-sm font-bold text-dark-text">{reference.productName}</h3> <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"> <p className="mt-1 text-xs font-semibold text-dark-muted">
@@ -671,10 +697,19 @@ const Registrations = () => {
</div> </div>
<div className="flex shrink-0 items-start gap-3"> <div className="flex shrink-0 items-start gap-3">
<div className="text-right"> <div className="text-right">
<div className="text-lg font-bold text-emerald-300">{formatNumber(getAverageYield(reference), 3)} /kg</div> <div className="text-lg font-bold text-emerald-300">
<div className="text-[10px] font-bold uppercase tracking-widest text-dark-muted"> {formatConsumptionPerPiece(reference) || `${formatNumber(getAverageYield(reference), 3)} pç/kg`}
{Object.keys(reference.sizeYields || {}).length ? 'media' : 'geral'}
</div> </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> </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"> <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" /> <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 { exportToCSV, fetchProductAnalytics, fetchProductionOrders } from '../dataService';
import { encodeProductGroupKey, parseProductName, sortProductSizes } from '../productParsing'; import { encodeProductGroupKey, parseProductName, sortProductSizes } from '../productParsing';
import { formatColorLabel } from '../displayFormatters'; import { formatColorLabel } from '../displayFormatters';
import { getPlanningStock } from '../planningStock';
type ReplenishmentStatus = 'need' | 'covered' | 'no_sales' | 'no_stock'; type ReplenishmentStatus = 'need' | 'covered' | 'no_sales' | 'no_stock';
type ReplenishmentFilter = 'all' | ReplenishmentStatus; type ReplenishmentFilter = 'all' | ReplenishmentStatus;
@@ -174,7 +175,7 @@ const Replenishment = () => {
const dailySales = product.quantitySold / rangeDays; const dailySales = product.quantitySold / rangeDays;
const projectedDemand = dailySales * targetCoverageDays; const projectedDemand = dailySales * targetCoverageDays;
const openProductionQuantity = openProductionByProductId[product.id] || 0; const openProductionQuantity = openProductionByProductId[product.id] || 0;
const availableQuantity = product.stock + openProductionQuantity; const availableQuantity = getPlanningStock(product.stock) + openProductionQuantity;
const rawNeed = projectedDemand - availableQuantity; const rawNeed = projectedDemand - availableQuantity;
const suggestedQuantity = Math.max(0, Math.ceil(rawNeed)); const suggestedQuantity = Math.max(0, Math.ceil(rawNeed));
const daysOfCover = dailySales > 0 ? availableQuantity / dailySales : null; const daysOfCover = dailySales > 0 ? availableQuantity / dailySales : null;

View File

@@ -28,12 +28,13 @@ import DateRangePicker from '../components/DateRangePicker';
import PaginationControls from '../components/PaginationControls'; import PaginationControls from '../components/PaginationControls';
import ProductTypeBadge from '../components/ProductTypeBadge'; import ProductTypeBadge from '../components/ProductTypeBadge';
import { classifyCutFamily } from '../analytics/cutting'; import { classifyCutFamily } from '../analytics/cutting';
import { adjustSupplyLotInventory, approveSupplyReceipt, consumeSupplyLotForProduction, createSupplyFabricPlan, createSupplyReceipt, deleteSupplyFabricPlan, deleteSupplyReceipt, fetchCuttingSettings, fetchProductAnalytics, fetchSupplySummary } from '../dataService'; import { adjustSupplyLotInventory, approveSupplyReceipt, consumeSupplyLotForProduction, createSupplyFabricPlan, createSupplyReceipt, deleteSupplyFabricPlan, deleteSupplyReceipt, downloadDatabaseDiagnostic, fetchCuttingSettings, fetchProductAnalytics, fetchStock, fetchSupplySummary, isSuperAdmin } from '../dataService';
import { parseProductName } from '../productParsing'; import { parseProductName } from '../productParsing';
import { resolveProductType, type ProductTypeKey } from '../productClassification'; import { resolveProductType, type ProductTypeKey } from '../productClassification';
import type { CuttingSettings, DateRange, ProductAnalyticsItem, SupplyFabricPlan, SupplyLot, SupplyMovement, SupplyPurchaseNeed, SupplyReceipt, SupplySummary } from '../types'; import { getPlanningStock } from '../planningStock';
import type { CuttingSettings, DateRange, ProductAnalyticsItem, StockData, SupplyFabricPlan, SupplyLot, SupplyMovement, SupplyPurchaseNeed, SupplyReceipt, SupplySummary } from '../types';
type InventoryTab = 'dashboard' | 'balance' | 'receipts' | 'inventory' | 'movements'; type InventoryTab = 'dashboard' | 'stock' | 'balance' | 'receipts' | 'inventory' | 'movements';
type ReceiptView = 'new' | 'pending' | 'history'; type ReceiptView = 'new' | 'pending' | 'history';
const pageClassName = 'flex w-full flex-col gap-6'; const pageClassName = 'flex w-full flex-col gap-6';
@@ -99,6 +100,7 @@ const exportCsv = (filename: string, rows: Array<Record<string, string | number
const inventoryTabs: Array<{ id: InventoryTab; name: string; icon: typeof BarChart3 }> = [ const inventoryTabs: Array<{ id: InventoryTab; name: string; icon: typeof BarChart3 }> = [
{ id: 'dashboard', name: 'Dashboard', icon: Warehouse }, { id: 'dashboard', name: 'Dashboard', icon: Warehouse },
{ id: 'stock', name: 'Estoque', icon: PackageSearch },
{ id: 'balance', name: 'Saldo', icon: BarChart3 }, { id: 'balance', name: 'Saldo', icon: BarChart3 },
{ id: 'receipts', name: 'Recebimentos', icon: Truck }, { id: 'receipts', name: 'Recebimentos', icon: Truck },
{ id: 'inventory', name: 'Inventário', icon: ClipboardCheck }, { id: 'inventory', name: 'Inventário', icon: ClipboardCheck },
@@ -190,9 +192,44 @@ const ModuleCard = ({
); );
}; };
const SuppliesHub = () => ( const SuppliesHub = () => {
const [isExportingDiagnostic, setIsExportingDiagnostic] = useState(false);
const [diagnosticError, setDiagnosticError] = useState('');
const canExportDiagnostic = isSuperAdmin();
const handleExportDiagnostic = async () => {
setIsExportingDiagnostic(true);
setDiagnosticError('');
try {
await downloadDatabaseDiagnostic();
} catch (error) {
setDiagnosticError(error instanceof Error ? error.message : 'Não foi possível exportar o diagnóstico do banco.');
} finally {
setIsExportingDiagnostic(false);
}
};
return (
<div className={pageClassName}> <div className={pageClassName}>
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
<Header title="Suprimentos" subtitle="Corte, estoque, malha e compras em um fluxo operacional." /> <Header title="Suprimentos" subtitle="Corte, estoque, malha e compras em um fluxo operacional." />
{canExportDiagnostic && (
<button
type="button"
onClick={handleExportDiagnostic}
disabled={isExportingDiagnostic}
className={`${buttonClassName} w-fit disabled:cursor-not-allowed disabled:opacity-60`}
>
<Download className="h-4 w-4" />
{isExportingDiagnostic ? 'Exportando...' : 'Exportar diagnóstico DB'}
</button>
)}
</div>
{diagnosticError && (
<div className="rounded-xl border border-red-500/30 bg-red-500/10 px-4 py-3 text-sm font-bold text-red-300">
{diagnosticError}
</div>
)}
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2"> <div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
<ModuleCard title="Planejamento Operacional" description="Prioridades de reposição, compra e revisão calculadas por demanda, estoque e cobertura." icon={PackageSearch} to="/supplies/current-planning" /> <ModuleCard title="Planejamento Operacional" description="Prioridades de reposição, compra e revisão calculadas por demanda, estoque e cobertura." icon={PackageSearch} to="/supplies/current-planning" />
<ModuleCard title="Planejamento de Corte" description="Ruptura por cor e tamanho, montagem do corte e prioridade por cobertura." icon={Scissors} to="/cutting" /> <ModuleCard title="Planejamento de Corte" description="Ruptura por cor e tamanho, montagem do corte e prioridade por cobertura." icon={Scissors} to="/cutting" />
@@ -202,7 +239,8 @@ const SuppliesHub = () => (
<ModuleCard title="Necessidade de Compra" description="Itens abaixo do mínimo e necessidade projetada para compra." icon={BarChart3} to="/supplies/purchase-needs" /> <ModuleCard title="Necessidade de Compra" description="Itens abaixo do mínimo e necessidade projetada para compra." icon={BarChart3} to="/supplies/purchase-needs" />
</div> </div>
</div> </div>
); );
};
const getRangeDays = (range: DateRange) => { const getRangeDays = (range: DateRange) => {
const start = new Date(range.start); const start = new Date(range.start);
@@ -230,9 +268,10 @@ const buildDemandRow = (
const color = override?.color || metadata.color; const color = override?.color || metadata.color;
const size = (override?.size || metadata.size).toUpperCase(); const size = (override?.size || metadata.size).toUpperCase();
const dailySales = product.quantitySold / rangeDays; const dailySales = product.quantitySold / rangeDays;
const daysOfCover = dailySales > 0 ? product.stock / dailySales : null; const planningStock = getPlanningStock(product.stock);
const daysOfCover = dailySales > 0 ? planningStock / dailySales : null;
const targetDemand = dailySales * targetCoverageDays; const targetDemand = dailySales * targetCoverageDays;
const suggestedUnits = Math.max(0, Math.ceil(targetDemand - product.stock)); const suggestedUnits = Math.max(0, Math.ceil(targetDemand - planningStock));
const missingData: string[] = []; const missingData: string[] = [];
if (planningReviewTypes.has(productType)) missingData.push('tipo'); if (planningReviewTypes.has(productType)) missingData.push('tipo');
@@ -956,6 +995,173 @@ const ReceiptsTab = ({
); );
}; };
const StockTab = ({
stock,
onRefresh,
}: {
stock: StockData[];
onRefresh: () => void;
}) => {
const [search, setSearch] = useState('');
const [stockFilter, setStockFilter] = useState('all');
const [currentPage, setCurrentPage] = useState(1);
const [itemsPerPage, setItemsPerPage] = useState(20);
const normalizedSearch = normalizeSearch(search);
const totalBalance = stock.reduce((total, item) => total + Number(item.saldo || 0), 0);
const updatedItems = stock.filter(item => Number(item.delta_estoque || 0) !== 0).length;
const latestUpdate = stock
.map(item => item.updated_at ? new Date(item.updated_at).getTime() : 0)
.filter(Boolean)
.sort((a, b) => b - a)[0] || null;
const visibleStock = stock.filter(item => {
const balance = Number(item.saldo || 0);
const delta = Number(item.delta_estoque || 0);
const matchesSearch = !normalizedSearch || normalizeSearch(`${item.produto_id} ${item.nome}`).includes(normalizedSearch);
const matchesFilter =
stockFilter === 'all' ||
(stockFilter === 'positive' && balance > 0) ||
(stockFilter === 'empty' && balance <= 0) ||
(stockFilter === 'changed' && delta !== 0);
return matchesSearch && matchesFilter;
}).sort((a, b) => Number(b.saldo || 0) - Number(a.saldo || 0));
const totalPages = Math.ceil(visibleStock.length / itemsPerPage);
const safeCurrentPage = Math.min(currentPage, totalPages || 1);
const startIndex = (safeCurrentPage - 1) * itemsPerPage;
const paginatedStock = visibleStock.slice(startIndex, startIndex + itemsPerPage);
return (
<div className="space-y-4">
<div className="grid grid-cols-1 gap-3 md:grid-cols-4">
<div className="rounded-xl border border-dark-border bg-dark-card p-4 shadow-sm">
<p className="text-xs font-bold uppercase tracking-widest text-dark-muted">SKUs</p>
<p className="mt-2 text-2xl font-bold text-dark-text">{formatNumber(stock.length, 0)}</p>
</div>
<div className="rounded-xl border border-dark-border bg-dark-card p-4 shadow-sm">
<p className="text-xs font-bold uppercase tracking-widest text-dark-muted">Saldo total</p>
<p className="mt-2 text-2xl font-bold text-dark-text">{formatNumber(totalBalance, 0)} un.</p>
</div>
<div className="rounded-xl border border-dark-border bg-dark-card p-4 shadow-sm">
<p className="text-xs font-bold uppercase tracking-widest text-dark-muted">Com delta</p>
<p className="mt-2 text-2xl font-bold text-dark-text">{formatNumber(updatedItems, 0)}</p>
</div>
<div className="rounded-xl border border-dark-border bg-dark-card p-4 shadow-sm">
<p className="text-xs font-bold uppercase tracking-widest text-dark-muted">Última atualização</p>
<p className="mt-2 text-2xl font-bold text-dark-text">{latestUpdate ? formatDateTime(new Date(latestUpdate).toISOString()) : '-'}</p>
</div>
</div>
<div className={`${panelClassName} p-4`}>
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div>
<h2 className="text-base font-bold text-dark-text">Estoque</h2>
<p className="mt-1 text-sm font-semibold text-dark-muted">Saldo de produtos sincronizado pelo fluxo de estoque para Graphs.</p>
</div>
<div className="flex flex-col gap-2 sm:flex-row">
<button
type="button"
onClick={() => exportCsv('estoque.csv', visibleStock.map(item => ({
produto_id: item.produto_id,
nome: item.nome,
saldo: item.saldo,
delta_estoque: item.delta_estoque,
atualizado_em: item.updated_at || '',
})))}
className={buttonClassName}
>
<Download className="h-4 w-4" /> CSV
</button>
<button type="button" onClick={onRefresh} className={buttonClassName}>
<RefreshCw className="h-4 w-4" /> Atualizar
</button>
</div>
</div>
<div className="mt-4 grid grid-cols-1 gap-3 lg:grid-cols-[1fr_220px]">
<label className="relative">
<Search className="pointer-events-none absolute left-3 top-3 h-4 w-4 text-dark-muted" />
<input
value={search}
onChange={(event) => {
setSearch(event.target.value);
setCurrentPage(1);
}}
className={`${inputClassName} pl-9`}
placeholder="Buscar por SKU ou produto..."
/>
</label>
<select
value={stockFilter}
onChange={(event) => {
setStockFilter(event.target.value);
setCurrentPage(1);
}}
className={inputClassName}
>
<option value="all">Todos os saldos</option>
<option value="positive">Com saldo</option>
<option value="empty">Sem saldo</option>
<option value="changed">Com delta</option>
</select>
</div>
{visibleStock.length ? (
<div className="mt-4 overflow-hidden rounded-xl border border-dark-border">
<div className="hidden grid-cols-[140px_1fr_120px_120px_160px] border-b border-dark-border bg-dark-input/40 px-4 py-3 text-xs font-bold uppercase tracking-widest text-dark-muted md:grid">
<span>SKU</span>
<span>Produto</span>
<span className="text-right">Saldo</span>
<span className="text-right">Delta</span>
<span className="text-right">Atualizado</span>
</div>
<div className="divide-y divide-dark-border">
{paginatedStock.map(item => {
const delta = Number(item.delta_estoque || 0);
return (
<div key={item.produto_id} className="grid grid-cols-1 gap-2 bg-dark-card px-4 py-3 md:grid-cols-[140px_1fr_120px_120px_160px] md:items-center">
<p className="font-mono text-xs font-bold text-dark-muted">#{item.produto_id}</p>
<p className="text-sm font-bold text-dark-text">{item.nome}</p>
<p className="text-sm font-bold text-dark-text md:text-right">{formatNumber(Number(item.saldo || 0), 0)} un.</p>
<p className={`text-sm font-bold md:text-right ${delta > 0 ? 'text-emerald-300' : delta < 0 ? 'text-red-300' : 'text-dark-muted'}`}>
{delta > 0 ? '+' : ''}{formatNumber(delta, 0)}
</p>
<p className="text-xs font-semibold text-dark-muted md:text-right">{formatDateTime(item.updated_at || null)}</p>
</div>
);
})}
</div>
<PaginationControls
totalItems={visibleStock.length}
currentPage={safeCurrentPage}
totalPages={totalPages}
pageSize={itemsPerPage}
pageSizeOptions={[20, 50, 100]}
itemLabel="SKUs"
pageSizeLabel="por página"
startIndex={startIndex}
endIndex={Math.min(startIndex + itemsPerPage, visibleStock.length)}
onPageChange={setCurrentPage}
onPageSizeChange={(pageSize) => {
setItemsPerPage(pageSize);
setCurrentPage(1);
}}
className="border-t border-dark-border px-4 py-3"
/>
</div>
) : (
<div className={emptyStateClassName}>
<PackageSearch className="h-8 w-8 text-brand-primary" />
<h3 className="text-base font-bold text-dark-text">{stock.length ? 'Nenhum SKU encontrado' : 'Nenhum estoque sincronizado'}</h3>
<p className="text-sm font-semibold text-dark-muted">
{stock.length ? 'Ajuste a busca ou o filtro.' : 'Quando o fluxo de estoque postar em /api/stock, os saldos aparecem aqui.'}
</p>
</div>
)}
</div>
</div>
);
};
const InventoryCountTab = ({ const InventoryCountTab = ({
lots, lots,
onAdjust, onAdjust,
@@ -1231,24 +1437,46 @@ const MovementsTab = ({
const InventoryScreen = () => { const InventoryScreen = () => {
const [activeTab, setActiveTab] = useState<InventoryTab>('dashboard'); const [activeTab, setActiveTab] = useState<InventoryTab>('dashboard');
const [summary, setSummary] = useState<SupplySummary>(emptySupplySummary); const [summary, setSummary] = useState<SupplySummary>(emptySupplySummary);
const [stockSnapshot, setStockSnapshot] = useState<StockData[]>([]);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [isBusy, setIsBusy] = useState(false); const [isBusy, setIsBusy] = useState(false);
const [errorMessage, setErrorMessage] = useState(''); const [errorMessage, setErrorMessage] = useState('');
const loadInventoryData = async () => {
setErrorMessage('');
const [nextSummary, nextStockSnapshot] = await Promise.all([
fetchSupplySummary(),
fetchStock(),
]);
setSummary(nextSummary);
setStockSnapshot(nextStockSnapshot);
};
const loadSummary = async () => { const loadSummary = async () => {
setErrorMessage(''); setErrorMessage('');
const nextSummary = await fetchSupplySummary(); const nextSummary = await fetchSupplySummary();
setSummary(nextSummary); setSummary(nextSummary);
}; };
const loadStockSnapshot = async () => {
setErrorMessage('');
setStockSnapshot(await fetchStock());
};
useEffect(() => { useEffect(() => {
let isMounted = true; let isMounted = true;
const load = async () => { const load = async () => {
setIsLoading(true); setIsLoading(true);
try { try {
const nextSummary = await fetchSupplySummary(); const [nextSummary, nextStockSnapshot] = await Promise.all([
if (isMounted) setSummary(nextSummary); fetchSupplySummary(),
fetchStock(),
]);
if (isMounted) {
setSummary(nextSummary);
setStockSnapshot(nextStockSnapshot);
}
} finally { } finally {
if (isMounted) setIsLoading(false); if (isMounted) setIsLoading(false);
} }
@@ -1266,7 +1494,7 @@ const InventoryScreen = () => {
setErrorMessage(''); setErrorMessage('');
try { try {
await action(); await action();
await loadSummary(); await loadInventoryData();
} catch (error) { } catch (error) {
setErrorMessage(error instanceof Error ? error.message : 'Não foi possível atualizar suprimentos.'); setErrorMessage(error instanceof Error ? error.message : 'Não foi possível atualizar suprimentos.');
} finally { } finally {
@@ -1302,6 +1530,7 @@ const InventoryScreen = () => {
) : ( ) : (
<> <>
{activeTab === 'dashboard' && <InventoryDashboard summary={summary} onNewReceipt={() => setActiveTab('receipts')} />} {activeTab === 'dashboard' && <InventoryDashboard summary={summary} onNewReceipt={() => setActiveTab('receipts')} />}
{activeTab === 'stock' && <StockTab stock={stockSnapshot} onRefresh={loadStockSnapshot} />}
{activeTab === 'balance' && <BalanceTab summary={summary} onRefresh={loadSummary} />} {activeTab === 'balance' && <BalanceTab summary={summary} onRefresh={loadSummary} />}
{activeTab === 'receipts' && ( {activeTab === 'receipts' && (
<ReceiptsTab <ReceiptsTab
@@ -1443,7 +1672,51 @@ const FabricPlanningScreen = () => {
<p className="mt-2 text-3xl font-bold text-dark-text">{plans.filter(plan => plan.priority === 'Crítico').length}</p> <p className="mt-2 text-3xl font-bold text-dark-text">{plans.filter(plan => plan.priority === 'Crítico').length}</p>
</div> </div>
</div> </div>
<div className="grid grid-cols-1 gap-6 xl:grid-cols-[420px_1fr]">
<div className="grid grid-cols-1 items-stretch gap-6 xl:grid-cols-[1fr_420px]">
<div className={`${panelClassName} flex flex-col overflow-hidden`}>
<div className="border-b border-dark-border p-5">
<h2 className="text-base font-bold text-dark-text">Planos de malha</h2>
<p className="mt-1 text-sm font-semibold text-dark-muted">Itens planejados para compra ou recebimento.</p>
</div>
{isLoading ? (
<div className={`${emptyStateClassName} min-h-[220px] flex-1`}>
<Ruler className="h-8 w-8 text-brand-primary" />
<h3 className="text-base font-bold text-dark-text">Carregando planos...</h3>
</div>
) : plans.length ? (
<div className="divide-y divide-dark-border">
{plans.map(plan => (
<div key={plan.id} className="grid grid-cols-1 gap-3 p-4 md:grid-cols-[1.4fr_1fr_100px_100px_44px] md:items-center">
<div>
<p className="font-bold text-dark-text">{plan.material}</p>
<p className="text-xs font-semibold text-dark-muted">{plan.color}</p>
</div>
<p className="text-sm font-semibold text-dark-muted">{plan.supplier}</p>
<p className="text-sm font-bold text-dark-text">{formatNumber(plan.quantityKg)} kg</p>
<span className="w-fit rounded-full border border-dark-border bg-dark-input px-2.5 py-1 text-xs font-bold text-dark-text">{plan.priority}</span>
<button
type="button"
title="Remover plano"
aria-label={`Remover plano ${plan.material}`}
disabled={isBusy}
onClick={() => removePlan(plan.id)}
className="inline-flex h-10 w-10 items-center justify-center rounded-lg text-red-400 transition-colors hover:bg-red-500/10 hover:text-red-300 disabled:cursor-not-allowed disabled:opacity-60 cursor-pointer"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
))}
</div>
) : (
<div className={`${emptyStateClassName} flex-1`}>
<Ruler className="h-8 w-8 text-brand-primary" />
<h3 className="text-base font-bold text-dark-text">Nenhum plano de malha cadastrado</h3>
<p className="text-sm font-semibold text-dark-muted">Cadastre o primeiro plano no formulário ao lado.</p>
</div>
)}
</div>
<form onSubmit={handleSubmit} className={`${panelClassName} p-5`}> <form onSubmit={handleSubmit} className={`${panelClassName} p-5`}>
<h2 className="text-base font-bold text-dark-text">Novo plano de malha</h2> <h2 className="text-base font-bold text-dark-text">Novo plano de malha</h2>
<div className="mt-4 grid grid-cols-1 gap-3"> <div className="mt-4 grid grid-cols-1 gap-3">
@@ -1479,49 +1752,6 @@ const FabricPlanningScreen = () => {
</button> </button>
</div> </div>
</form> </form>
<div className={`${panelClassName} overflow-hidden`}>
<div className="border-b border-dark-border p-5">
<h2 className="text-base font-bold text-dark-text">Planos de malha</h2>
<p className="mt-1 text-sm font-semibold text-dark-muted">Itens planejados para compra ou recebimento.</p>
</div>
{isLoading ? (
<div className={`${emptyStateClassName} min-h-[220px]`}>
<Ruler className="h-8 w-8 text-brand-primary" />
<h3 className="text-base font-bold text-dark-text">Carregando planos...</h3>
</div>
) : plans.length ? (
<div className="divide-y divide-dark-border">
{plans.map(plan => (
<div key={plan.id} className="grid grid-cols-1 gap-3 p-4 md:grid-cols-[1.4fr_1fr_100px_100px_44px] md:items-center">
<div>
<p className="font-bold text-dark-text">{plan.material}</p>
<p className="text-xs font-semibold text-dark-muted">{plan.color}</p>
</div>
<p className="text-sm font-semibold text-dark-muted">{plan.supplier}</p>
<p className="text-sm font-bold text-dark-text">{formatNumber(plan.quantityKg)} kg</p>
<span className="w-fit rounded-full border border-dark-border bg-dark-input px-2.5 py-1 text-xs font-bold text-dark-text">{plan.priority}</span>
<button
type="button"
title="Remover plano"
aria-label={`Remover plano ${plan.material}`}
disabled={isBusy}
onClick={() => removePlan(plan.id)}
className="inline-flex h-10 w-10 items-center justify-center rounded-lg text-red-400 transition-colors hover:bg-red-500/10 hover:text-red-300 disabled:cursor-not-allowed disabled:opacity-60 cursor-pointer"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
))}
</div>
) : (
<div className={emptyStateClassName}>
<Ruler className="h-8 w-8 text-brand-primary" />
<h3 className="text-base font-bold text-dark-text">Nenhum plano de malha cadastrado</h3>
<p className="text-sm font-semibold text-dark-muted">Cadastre o primeiro plano no formulário ao lado.</p>
</div>
)}
</div>
</div> </div>
</div> </div>
); );
@@ -1551,6 +1781,26 @@ const summarizePurchaseNeeds = (needs: SupplyPurchaseNeed[]) => {
return summaries.length ? summaries.join(' + ') : '0 kg'; return summaries.length ? summaries.join(' + ') : '0 kg';
}; };
const getNeedDisplayName = (need: SupplyPurchaseNeed) => (
need.material.replace(/^Cadastrar (consumo|rendimento):\s*/i, '')
);
const getNeedPendingLabel = (need: SupplyPurchaseNeed) => {
if (!need.missingReference) return purchaseStatusLabels[need.status];
return /^Cadastrar rendimento:/i.test(need.material) ? 'Rendimento' : 'Referência';
};
const sumNeedProducts = (
need: SupplyPurchaseNeed,
field: 'suggestedQuantity' | 'quantitySold' | 'stockQuantity'
) => (
(need.products || []).reduce((total, product) => total + Number(product[field] || 0), 0)
);
const countImpactedSkus = (needs: SupplyPurchaseNeed[]) => (
new Set(needs.flatMap(need => (need.products || []).map(product => product.productId).filter(Boolean))).size
);
const PurchaseNeedsScreen = () => { const PurchaseNeedsScreen = () => {
const [summary, setSummary] = useState<SupplySummary>(emptySupplySummary); const [summary, setSummary] = useState<SupplySummary>(emptySupplySummary);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
@@ -1585,7 +1835,7 @@ const PurchaseNeedsScreen = () => {
const normalizedSearch = normalizeSearch(search); const normalizedSearch = normalizeSearch(search);
const visibleNeeds = summary.purchaseNeeds.filter(need => ( const visibleNeeds = summary.purchaseNeeds.filter(need => (
!normalizedSearch || normalizeSearch(`${need.material} ${need.suppliers.join(' ')} ${need.colors.join(' ')}`).includes(normalizedSearch) !normalizedSearch || normalizeSearch(`${need.material} ${getNeedDisplayName(need)} ${need.suppliers.join(' ')} ${need.colors.join(' ')} ${(need.products || []).map(product => `${product.productId} ${product.name}`).join(' ')}`).includes(normalizedSearch)
)); ));
const totalPages = Math.ceil(visibleNeeds.length / itemsPerPage); const totalPages = Math.ceil(visibleNeeds.length / itemsPerPage);
const safeCurrentPage = Math.min(currentPage, totalPages || 1); const safeCurrentPage = Math.min(currentPage, totalPages || 1);
@@ -1595,17 +1845,36 @@ const PurchaseNeedsScreen = () => {
const suggestedPurchaseSummary = summarizePurchaseNeeds(summary.purchaseNeeds); const suggestedPurchaseSummary = summarizePurchaseNeeds(summary.purchaseNeeds);
const pendingSupplierCount = new Set(summary.purchaseNeeds.flatMap(need => need.suppliers)).size; const pendingSupplierCount = new Set(summary.purchaseNeeds.flatMap(need => need.suppliers)).size;
const missingReferenceCount = summary.purchaseNeeds.filter(need => need.missingReference).length; const missingReferenceCount = summary.purchaseNeeds.filter(need => need.missingReference).length;
const mappedNeedCount = summary.purchaseNeeds.length - missingReferenceCount;
return ( const impactedSkuCount = countImpactedSkus(summary.purchaseNeeds);
<div className={pageClassName}> const setupMode = missingReferenceCount > 0 && missingReferenceCount >= mappedNeedCount;
<Header title="Necessidade de Compra" subtitle="Materiais abaixo do mínimo e necessidade projetada para compra." backTo="/supplies" /> const pageTitle = 'Necessidade de Compra';
<div className="grid grid-cols-1 gap-4 md:grid-cols-4"> const pageSubtitle = setupMode
{[ ? 'Complete referências de consumo para liberar o cálculo real de compra por material.'
: 'Materiais abaixo do mínimo e necessidade projetada para compra.';
const panelTitle = setupMode ? 'Pendências de cadastro' : 'Necessidade por material';
const panelSubtitle = setupMode
? 'Priorize os SKUs com maior impacto e cadastre material, rendimento e unidade de consumo.'
: 'Planejado - estoque aprovado - recebimentos pendentes.';
const stats = setupMode
? [
{ label: 'Pendências', value: `${missingReferenceCount}` },
{ label: 'SKUs impactados', value: `${impactedSkuCount || missingReferenceCount}` },
{ label: 'Impacto estimado', value: suggestedPurchaseSummary },
{ label: 'Referências prontas', value: `${mappedNeedCount}` },
]
: [
{ label: 'Itens a comprar', value: `${purchaseItemCount}` }, { label: 'Itens a comprar', value: `${purchaseItemCount}` },
{ label: 'Compra sugerida', value: suggestedPurchaseSummary }, { label: 'Compra sugerida', value: suggestedPurchaseSummary },
{ label: 'Fornecedores', value: `${pendingSupplierCount}` }, { label: 'Fornecedores', value: `${pendingSupplierCount}` },
{ label: 'Sem referência', value: `${missingReferenceCount}` }, { label: 'Sem referência', value: `${missingReferenceCount}` },
].map(stat => ( ];
return (
<div className={pageClassName}>
<Header title={pageTitle} subtitle={pageSubtitle} backTo="/supplies" />
<div className="grid grid-cols-1 gap-4 md:grid-cols-4">
{stats.map(stat => (
<div key={stat.label} className="rounded-2xl border border-dark-border bg-dark-card p-5 shadow-sm"> <div key={stat.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">{stat.label}</p> <p className="text-xs font-bold uppercase tracking-widest text-dark-muted">{stat.label}</p>
<p className="mt-2 text-3xl font-bold text-dark-text">{stat.value}</p> <p className="mt-2 text-3xl font-bold text-dark-text">{stat.value}</p>
@@ -1615,19 +1884,20 @@ const PurchaseNeedsScreen = () => {
<div className={`${panelClassName} p-5`}> <div className={`${panelClassName} p-5`}>
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between"> <div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div> <div>
<h2 className="text-base font-bold text-dark-text">Necessidade por material</h2> <h2 className="text-base font-bold text-dark-text">{panelTitle}</h2>
<p className="mt-1 text-sm font-semibold text-dark-muted">Planejado - estoque aprovado - recebimentos pendentes.</p> <p className="mt-1 text-sm font-semibold text-dark-muted">{panelSubtitle}</p>
</div> </div>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
<button type="button" onClick={loadSummary} className={buttonClassName}><RefreshCw className="h-4 w-4" /> Atualizar</button> <button type="button" onClick={loadSummary} className={buttonClassName}><RefreshCw className="h-4 w-4" /> Atualizar</button>
<button <button
type="button" type="button"
onClick={() => exportCsv('necessidade-compra.csv', visibleNeeds.map(need => ({ onClick={() => exportCsv(setupMode ? 'pendencias-compra.csv' : 'necessidade-compra.csv', visibleNeeds.map(need => ({
material: need.material, material: getNeedDisplayName(need),
planejado_kg: need.plannedKg, pendencia: need.missingReference ? getNeedPendingLabel(need) : '',
estoque_kg: need.stockKg, planejado: need.plannedKg,
pendente_kg: need.pendingKg, estoque: need.stockKg,
comprar_kg: need.purchaseKg, pendente: need.pendingKg,
comprar: need.purchaseKg,
unidade: getNeedUnit(need), unidade: getNeedUnit(need),
prioridade: need.priority, prioridade: need.priority,
cobertura: need.missingReference ? 'Sem referência de consumo' : purchaseStatusLabels[need.status], cobertura: need.missingReference ? 'Sem referência de consumo' : purchaseStatusLabels[need.status],
@@ -1650,7 +1920,7 @@ const PurchaseNeedsScreen = () => {
setCurrentPage(1); setCurrentPage(1);
}} }}
className={`${inputClassName} pl-9`} className={`${inputClassName} pl-9`}
placeholder="Buscar por material, fornecedor ou cor..." placeholder={setupMode ? 'Buscar por SKU, produto ou pendência...' : 'Buscar por material, fornecedor ou cor...'}
/> />
</label> </label>
{isLoading ? ( {isLoading ? (
@@ -1661,6 +1931,72 @@ const PurchaseNeedsScreen = () => {
) : visibleNeeds.length ? ( ) : visibleNeeds.length ? (
<div className="mt-4 overflow-hidden rounded-xl border border-dark-border"> <div className="mt-4 overflow-hidden rounded-xl border border-dark-border">
<div className="overflow-x-auto"> <div className="overflow-x-auto">
{setupMode ? (
<table className="w-full min-w-[880px] table-fixed border-collapse">
<colgroup>
<col className="w-[34%]" />
<col className="w-[12%]" />
<col className="w-[14%]" />
<col className="w-[15%]" />
<col className="w-[15%]" />
<col className="w-[10%]" />
</colgroup>
<thead className="border-b border-dark-border bg-dark-input/40 text-xs font-bold uppercase tracking-widest text-dark-muted">
<tr>
<th scope="col" className="px-4 py-3 text-left">Produto</th>
<th scope="col" className="px-4 py-3 text-right">Vendido</th>
<th scope="col" className="px-4 py-3 text-right">Estoque produto</th>
<th scope="col" className="px-4 py-3 text-right">Impacto estimado</th>
<th scope="col" className="px-4 py-3 text-right">Pendência</th>
<th scope="col" className="px-4 py-3 text-right">Ação</th>
</tr>
</thead>
<tbody className="divide-y divide-dark-border bg-dark-card">
{paginatedNeeds.map(need => {
const referenceProduct = need.products?.[0];
const productName = referenceProduct?.name || getNeedDisplayName(need);
const productId = referenceProduct?.productId || '';
const soldQuantity = referenceProduct?.quantitySold ?? sumNeedProducts(need, 'quantitySold');
const stockQuantity = referenceProduct?.stockQuantity ?? sumNeedProducts(need, 'stockQuantity');
return (
<tr key={need.material}>
<td className="px-4 py-4 align-middle">
<div className="min-w-0">
<p className="truncate text-sm font-bold text-dark-text">{productName}</p>
<p className="mt-1 text-xs font-semibold text-dark-muted">
{productId ? `SKU ${productId}` : 'SKU não informado'}
</p>
</div>
</td>
<td className="px-4 py-4 text-right align-middle text-sm font-bold text-dark-text">{formatNumber(soldQuantity, 0)} un.</td>
<td className="px-4 py-4 text-right align-middle text-sm font-bold text-dark-text">{formatNumber(stockQuantity, 0)} un.</td>
<td className="px-4 py-4 text-right align-middle text-sm font-bold text-amber-300">{formatNeedQuantity(need, need.purchaseKg)}</td>
<td className="px-4 py-4 text-right align-middle">
<span className="inline-flex w-fit rounded-full border border-sky-400/30 bg-sky-400/10 px-2.5 py-1 text-xs font-bold text-sky-300">
{getNeedPendingLabel(need)}
</span>
</td>
<td className="px-4 py-4 text-right align-middle">
{referenceProduct ? (
<RouterLink
to={buildConsumptionReferencePath({ sku: referenceProduct.productId, name: referenceProduct.name })}
className="inline-flex h-9 w-9 items-center justify-center rounded-lg bg-dark-input text-dark-text transition-colors hover:bg-dark-border"
title={`Cadastrar referência do SKU ${referenceProduct.productId}`}
aria-label={`Cadastrar referência do SKU ${referenceProduct.productId}`}
>
<Pencil className="h-4 w-4" />
</RouterLink>
) : (
<span className="text-xs font-semibold text-dark-muted">Sem SKU</span>
)}
</td>
</tr>
);
})}
</tbody>
</table>
) : (
<table className="w-full min-w-[920px] table-fixed border-collapse"> <table className="w-full min-w-[920px] table-fixed border-collapse">
<colgroup> <colgroup>
<col /> <col />
@@ -1687,7 +2023,7 @@ const PurchaseNeedsScreen = () => {
<tr key={need.material}> <tr key={need.material}>
<td className="px-4 py-4 align-middle"> <td className="px-4 py-4 align-middle">
<div className="min-w-0"> <div className="min-w-0">
<p className="text-sm font-bold text-dark-text">{need.material}</p> <p className="text-sm font-bold text-dark-text">{getNeedDisplayName(need)}</p>
<div className="mt-1 flex flex-wrap items-center gap-2"> <div className="mt-1 flex flex-wrap items-center gap-2">
<p className="text-xs font-semibold text-dark-muted"> <p className="text-xs font-semibold text-dark-muted">
{need.missingReference {need.missingReference
@@ -1735,6 +2071,7 @@ const PurchaseNeedsScreen = () => {
})} })}
</tbody> </tbody>
</table> </table>
)}
</div> </div>
<PaginationControls <PaginationControls
totalItems={visibleNeeds.length} totalItems={visibleNeeds.length}

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 (!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, /\bKIT\b/)) return 'kit_bundle';
if (has(normalizedName, /TRANSPARENTE PP|SACO DE SEGURANCA|MILHEIRO|SACOLA|EMBALAGEM/)) return 'packaging'; if (has(normalizedName, /TRANSPARENTE PP|SACO DE SEGURANCA|MILHEIRO|SACOLA|EMBALAGEM/)) return 'packaging';
if (has(normalizedName, /PRENSA|MAQUINA|OVERLOCK/)) return 'equipment'; if (has(normalizedName, /PRENSA|MAQUINA|OVERLOCK|OVERLOK|GALONEIRA|PRATELEIRA/)) return 'equipment';
if (has(normalizedName, /FRETE|SERVICO|TRANSPORTE|MOTOTAXI|TECELAGEM|TINTURARIA/)) return 'service'; 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|PECA DE MAQUINA/)) return 'machine_part'; 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/)) return 'dtf_input'; 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|CORRECAO IMPRESSAO|ESTAMPA|PERSONALIZACAO/)) return 'dtf_service'; 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, /MALHA|RIBANA|FIO|TECIDO|RESIDUO|RETALHO|PIMA|ALGODAO/)) {
if (!has(normalizedName, /CAMISETA|MOLETOM|REGATA|BONE|CHINELO|OVERSIZE|OVER SIZE/)) { if (!has(normalizedName, /CAMISETA|MOLETOM|REGATA|BONE|CHINELO|OVERSIZE|OVER SIZE/)) {
return 'raw_material'; 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 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 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(); 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) => { export const encodeProductGroupKey = (value: string) => {
const normalizedValue = normalizeProductText(value); const normalizedValue = normalizeProductText(value);
const bytes = new TextEncoder().encode(normalizedValue); 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 explicitSizeMatch = cleanName.match(/\bTAMANHO\s*-?\s*([A-Z0-9]+)\b/i);
const trailingTokenMatch = cleanName.match(/(?:\s+-\s+|\s)([A-Z0-9]+)$/i); const trailingTokenMatch = cleanName.match(/(?:\s+-\s+|\s)([A-Z0-9]+)$/i);
const trailingToken = trailingTokenMatch?.[1]?.toUpperCase() || ''; 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 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 let baseName = cleanName
.replace(/\bCOR\s+.+?(?:\s+TAMANHO\s*-?\s*[A-Z0-9]+|\s+-\s+[A-Z0-9]+$|$)/i, '') .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(/\bTAMANHO\s*-?\s*[A-Z0-9]+\b/i, '')
.replace(/\s+-\s*[A-Z0-9]+$/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, '')); baseName = normalizeProductText(baseName.replace(/\s+-\s*$/g, ''));
return { return {

View File

@@ -34,6 +34,26 @@ export interface ProductionOrderMarker {
color?: string | null; 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 { export interface ProductionOrderItem {
id: number; id: number;
tinyId: string; tinyId: string;
@@ -48,7 +68,16 @@ export interface ProductionOrderItem {
quantity: number; quantity: number;
unit: string; unit: string;
integrationStatus: string; integrationStatus: string;
notes: string;
supplier: string;
lotCode: string;
rollQuantity: number | null;
fabricKg: number | null;
ribKg: number | null;
yieldPiecesPerKg: number | null;
markers: ProductionOrderMarker[]; markers: ProductionOrderMarker[];
components: ProductionOrderComponent[];
steps: ProductionOrderStep[];
createdAt: string | null; createdAt: string | null;
updatedAt: string | null; updatedAt: string | null;
} }
@@ -67,6 +96,35 @@ export interface ProductionOrderSummary {
counts: ProductionOrderCounts; 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 type CutFamilyKey = 'BLCS' | 'BLOS' | 'BLMC' | 'BLPM' | 'OUTROS';
export interface CutProductOverride { export interface CutProductOverride {
@@ -127,6 +185,10 @@ export interface ConsumptionReference {
efficiencyPercent: number | null; efficiencyPercent: number | null;
ribGPerPiece: number | null; ribGPerPiece: number | null;
materialCostPerKg: number | null; materialCostPerKg: number | null;
consumptionQuantity: number | null;
consumptionUnit: string;
source: string;
lastProductionOrderId: number | null;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
} }
@@ -168,6 +230,8 @@ export type ConsumptionReferencePayload = {
efficiencyPercent?: number | string | null; efficiencyPercent?: number | string | null;
ribGPerPiece?: number | string | null; ribGPerPiece?: number | string | null;
materialCostPerKg?: number | string | null; materialCostPerKg?: number | string | null;
consumptionQuantity?: number | string | null;
consumptionUnit?: string | null;
}; };
export type SupplyReceiptStatus = 'pending' | 'approved'; export type SupplyReceiptStatus = 'pending' | 'approved';
@@ -246,6 +310,8 @@ export interface SupplyPurchaseNeed {
quantitySold: number; quantitySold: number;
stockQuantity: number; stockQuantity: number;
yieldPerKg?: number; yieldPerKg?: number;
consumptionQuantity?: number;
consumptionUnit?: string;
}>; }>;
} }
@@ -409,6 +475,29 @@ 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 ClientAnalyticsItem { export interface ClientAnalyticsItem {
customerKey: string; customerKey: string;
clientToken: string; clientToken: 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;
}