Compare commits
70 Commits
f92348958b
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0e06e800ec | ||
|
|
7d89c134cb | ||
|
|
2498437f25 | ||
|
|
e0fe15c3dc | ||
|
|
7831d13c7e | ||
|
|
ff0f73e87c | ||
|
|
f18e7c4845 | ||
|
|
2d738073bf | ||
|
|
59fe9104d6 | ||
|
|
f69efb5358 | ||
|
|
0ff267be2c | ||
|
|
7939a6b078 | ||
|
|
fb8b827c73 | ||
|
|
60ded40595 | ||
|
|
42cee3f505 | ||
|
|
ef3bf4ce5c | ||
|
|
6d0d8e6cf0 | ||
|
|
635103b924 | ||
|
|
7fcf52cf67 | ||
|
|
e993bfdaf3 | ||
|
|
1a3fed3dda | ||
|
|
37d4a7732e | ||
|
|
b431802d50 | ||
|
|
5559e9951a | ||
|
|
9c7a383ee2 | ||
|
|
496f9d432d | ||
|
|
9a7e0d6818 | ||
|
|
6afd0c0392 | ||
|
|
c741e520ea | ||
|
|
ed3a3f0571 | ||
|
|
20dde11ff0 | ||
|
|
4857438e6f | ||
|
|
252c2447a8 | ||
|
|
a7c732e139 | ||
|
|
302b55b346 | ||
|
|
e8eafaf9b9 | ||
|
|
5aa1be27bb | ||
|
|
5c9ab6bb11 | ||
|
|
800eb976ab | ||
|
|
c07938c94e | ||
|
|
615e07ad32 | ||
|
|
c07264fd69 | ||
|
|
9c77475ce5 | ||
|
|
eaa0a5010d | ||
|
|
a6e07e46d9 | ||
|
|
166f97c629 | ||
|
|
98d278947b | ||
|
|
c7536d9097 | ||
|
|
b4e51eb8eb | ||
|
|
51b73ed82b | ||
|
|
eb5cc01d56 | ||
|
|
4c3df482be | ||
|
|
377754c26e | ||
|
|
bc74a1089b | ||
|
|
f992517891 | ||
|
|
6283f86091 | ||
|
|
e1f001b57d | ||
|
|
88089343a8 | ||
|
|
9af5f296a7 | ||
|
|
3066ba06a2 | ||
|
|
6608957574 | ||
|
|
1dcc565299 | ||
|
|
0cae89277f | ||
|
|
e332374b2d | ||
|
|
e62ec6052e | ||
|
|
4bfd0d4591 | ||
|
|
897d4cbe66 | ||
|
|
8eeadf00ae | ||
|
|
422828f343 | ||
|
|
5e81a8f2c3 |
@@ -20,6 +20,15 @@ ADMIN_EMAIL=admin@admin.com
|
||||
ADMIN_PASSWORD=admin123
|
||||
JWT_SECRET=super_secret_jwt_key_123
|
||||
|
||||
# --- CAPTCHA / Bot Protection (Optional) ---
|
||||
# Create keys in Cloudflare Turnstile and set both values in production.
|
||||
# When TURNSTILE_SECRET is empty, backend CAPTCHA enforcement is disabled.
|
||||
TURNSTILE_SITE_KEY=
|
||||
TURNSTILE_SECRET=
|
||||
# Backward-compatible aliases also accepted by the backend:
|
||||
# VITE_TURNSTILE_SITE_KEY=
|
||||
# TURNSTILE_SECRET_KEY=
|
||||
|
||||
# --- Frontend Configuration (Optional) ---
|
||||
# If you need to override the API URL for the frontend
|
||||
# VITE_API_URL=/api
|
||||
|
||||
100
CONTEXT.md
100
CONTEXT.md
@@ -37,6 +37,8 @@ This project (often referred to as "Nexstar Graphs" or simply "Graphs") is a rea
|
||||
* **Backend Analytics Foundation:** Raw `/api/data` still exists for legacy pages, but dashboard/product/client aggregate endpoints now exist under `/api/analytics/*`. Dashboard already uses the backend dashboard aggregation endpoint so it does not wait on the full raw order download.
|
||||
* **Route Code Splitting:** Frontend routes are lazy-loaded with `React.lazy`/`Suspense` so the first JS payload stays smaller. Recharts-heavy page chunks are loaded on demand.
|
||||
* **Analytics Client Cache:** Frontend analytics requests use a small shared cache/deduping layer in `src/dataService.ts`. Navigating between Dashboard, RFV, Products, and Clients should reuse fresh responses instead of refetching every route mount unless query params change or the cache expires.
|
||||
* **Tiny as Current Source, Graphs as Future Operating Layer:** Tiny ERP is the current source for sales, product/order metadata, and stock. Graphs should increasingly become the place where the business manages production intelligence: cut configuration, SKU families, raw material relationships, consumption references, and operational charts. `NECESSIDADE DE CORTE.xlsx` is reference material only and must not become a runtime dependency.
|
||||
* **Tiny/Olist Composition Import:** Product structures from Tiny/Olist V3 are imported into `product_compositions` and `product_composition_components`. Sellable finished-product structures are also promoted into local catalog products/materials and `consumption_references` with source `tiny_structure`, so purchase planning can use real component consumption instead of guessed kg-per-piece rules.
|
||||
|
||||
## 4. Directory Structure
|
||||
```text
|
||||
@@ -83,15 +85,69 @@ This project (often referred to as "Nexstar Graphs" or simply "Graphs") is a rea
|
||||
* **RFV Display Rule:** The RFV table's `Última Compra` date is the purchase date inside the selected period. Date-only strings must be parsed as local dates in the frontend to avoid UTC shifting (`2026-06-15` must render as `15/06/2026`, not `14/06/2026`). The small recency text below it displays `Hoje`, `Ontem`, or `X dias` relative to the selected range end.
|
||||
* **Client Page Filters:** The main Clients page has one filter button plus client search. The filter panel contains date shortcuts/custom dates, sorting, marketplace, sales channel, seller, and client type. Marketplace/channel/seller options come from distinct non-empty order metadata across all orders so options remain visible regardless of the current date window.
|
||||
* **Seller Metadata Display:** Seller labels are cleaned for UI display. All-caps seller names are title-cased, trailing Tiny IDs like `#977226210` are stripped from labels, and leading fantasy-name prefixes like `[3]` are removed for display. IDs may still be used internally as stable filter values.
|
||||
* **Seller Dashboard Charts:** The main Dashboard includes seller performance charts above the existing charts, using backend aggregates for revenue and order count by seller.
|
||||
* **Seller Dashboard Charts:** The main Dashboard includes seller performance charts above the existing charts, using backend aggregates for revenue and order count by seller. Long date ranges are bucketed into week/month views for readability. Seller labels like `0`/empty values are displayed as `Sem vendedor`. The seller moving-average line is optional behind the `Tendência` toggle because it can be confused with another seller series.
|
||||
* **Product Chart Readability:** Product detail charts use automatic date bucketing, cleaner x-axis labels, clickable point drill-downs, and a moving average on the single-product view. Product detail KPIs include daily average sold and estimated stock coverage based on the selected date range.
|
||||
|
||||
## 6. CI/CD & Deployment
|
||||
## 6. Supply, Cutting, and Data Flow Requirements
|
||||
Tiny provides sales and stock facts, but it does not fully describe how a finished SKU consumes raw material. For Suprimentos and Corte to work reliably, Graphs needs internal domain data that complements Tiny.
|
||||
|
||||
**Current operating assumption:**
|
||||
* Tiny sends orders, product IDs/names, seller/order metadata, and current stock.
|
||||
* Graphs derives demand, sales velocity, replenishment pressure, and cut/material needs from Tiny data plus local configuration.
|
||||
* Excel files are examples of current manual management only. They should guide UX/data modeling but not be imported as the long-term source of truth.
|
||||
|
||||
**Core missing relationship:**
|
||||
```text
|
||||
Finished SKU -> raw material -> consumption/yield
|
||||
```
|
||||
|
||||
Example:
|
||||
```text
|
||||
BASE LISA CAMISETA PRETA G
|
||||
uses: MALHA 100% ALGODAO PRETA
|
||||
yield: 4.8 units/kg
|
||||
```
|
||||
|
||||
**Data Graphs must own or enrich locally:**
|
||||
* **Finished SKU catalog:** SKU/id, name, color, size, product family, cut family, active/inactive.
|
||||
* **Raw material catalog:** material SKU/id, name, type (`malha`, `ribana`, `fio`, etc.), color, supplier, unit (`kg`, `metro`, `unidade`), and whether it is raw input instead of finished stock.
|
||||
* **Consumption references:** finished SKU, material product, yield per kg, optional yield by size, optional color/family overrides, and secondary materials when needed.
|
||||
* **Imported product compositions:** Tiny/Olist structures are stored as immutable-ish sync facts: finished SKU/Tiny ID/unit, component SKU/Tiny ID/name, quantity per finished unit, and component unit (`KG`, `UN`, etc.). These records support Product Details composition display and drive `tiny_structure` consumption references.
|
||||
* **Cut family mapping:** rules that say which colors/sizes/products can be planned together for cutting.
|
||||
* **Open production/cutting data:** quantities already in production, expected finish date, linked finished SKU, and linked raw material when available.
|
||||
* **Business rules:** target coverage days, minimum stock, safety stock, purchase lead time, production lead time, and discontinued/ignored SKU rules.
|
||||
|
||||
**Current UI/data-flow behavior:**
|
||||
* `Cadastros` has an `Importar composições` JSON upload action for exports like `composicoes_produtos_YYYY-MM-DD.json`. The same import is available to scripts through `POST /api/production-orders/tiny-compositions/import` using `x-api-key`.
|
||||
* Composition imports are idempotent. Re-importing the same Tiny/Olist export updates composition rows/components and refreshes derived `tiny_structure` consumption references instead of duplicating them.
|
||||
* `Product Details > Composição` shows the synced component list for the current product when a composition exists.
|
||||
* `Suprimentos > Necessidade de Compra` derives material pressure from demand and stock, but rows without a consumption reference are marked as missing reference instead of pretending to calculate kg.
|
||||
* Imported `tiny_structure` consumption references now allow `Necessidade de Compra` to calculate mixed-unit material demand from real BOM lines, e.g. malha/ribana in `KG` and etiqueta/ilhós/atacador in `UN`.
|
||||
* Missing-reference rows link into `Cadastros > Referência de Consumo` with SKU context. If the SKU does not exist in the local catalog yet, `Cadastros` opens the product form first.
|
||||
* `Cadastros > Referência de Consumo` labels reference sources as `Manual`, `Tiny OP`, or `Tiny Estrutura`.
|
||||
* `Plano de Corte` SKU edit actions deep-link into the cutting configuration drawer for that SKU.
|
||||
* Product tables and group detail tables are tuned for high-volume values, including large `Total Vendido` quantities above 100k.
|
||||
|
||||
**Current composition import status:**
|
||||
* Local import tested with `/home/farelos/Downloads/composicoes_produtos_2026-07-31.json`.
|
||||
* Imported successfully: `437` compositions, `1,552` component rows, and `1,316` `tiny_structure` consumption references.
|
||||
* Known data issues from that export: `4` sellable finished products are missing SKU; `54` structures are raw material/service structures and are stored as compositions but intentionally not promoted into sellable demand-planning references; `236` component/reference lines were skipped because their parent structure was non-sellable or the finished SKU was missing.
|
||||
* Sellable-family detection currently includes `DTF`, `CAMISETA`, `CAMISA`, `MOLETOM`, `CANGURU`, `REGATA`, and `POLO`. Raw/input/service structures are excluded from demand-reference promotion when descriptions contain terms like `MALHA`, `RIBANA`, `FIO`, `TECIDO`, `SERVIÇO`, `TINTURARIA`, `TECELAGEM`, or `FRETE`.
|
||||
|
||||
**Recommended next steps now that composition data exists:**
|
||||
* Make `Necessidade de Compra` clearer and more auditable: show `demanda do SKU -> consumo por unidade -> material necessário -> estoque atual -> comprar`.
|
||||
* Add a data-health/review screen for missing finished SKU, missing component stock link, suspicious quantities, products with sales but no composition, and non-sellable structures.
|
||||
* Connect material stock more explicitly to imported component SKUs/Tiny IDs so material purchase needs subtract the right current stock and pending receipts.
|
||||
* Upgrade `Planejamento de Corte` to show material blockers by family/color/size, using imported composition references and current stock.
|
||||
* Add production-plan status flow only after material needs and composition health are reliable.
|
||||
|
||||
## 7. CI/CD & Deployment
|
||||
* **Gitea Actions:** A workflow located in `.gitea/workflows/deploy.yml` triggers on pushes to the `main` branch.
|
||||
* **Docker Registry:** The pipeline builds the `frontend` and `backend` Docker images and pushes them directly to `gitea.blyzer.com.br/blyzer/`.
|
||||
* **Production Deployment:** Updates are deployed manually via Portainer by pulling the `latest` image tags from the Gitea registry and redeploying the stack.
|
||||
* **Environment Variables:** Security secrets (`API_KEY`, `JWT_SECRET`, `POSTGRES_PASSWORD`, `N8N_WHATSAPP_TRIGGER_URL`) are injected via the Portainer stack configuration and passed into containers via `docker-compose.yml`.
|
||||
|
||||
## 7. Environment Setup & Scripts
|
||||
## 8. Environment Setup & Scripts
|
||||
|
||||
**Running Locally:**
|
||||
1. Start the database:
|
||||
@@ -132,7 +188,18 @@ docker compose up -d --build
|
||||
```
|
||||
The local Docker services use `restart: unless-stopped`, so containers should come back after laptop restart if Docker starts.
|
||||
|
||||
## 8. Coding Standards & AI Directives
|
||||
**Importing Tiny/Olist Product Compositions:**
|
||||
* From the app: open `Cadastros` and use `Importar composições` with the exported JSON file.
|
||||
* From a script/API client:
|
||||
```bash
|
||||
curl -X POST http://localhost:3004/api/production-orders/tiny-compositions/import \
|
||||
-H 'Content-Type: application/json' \
|
||||
-H 'x-api-key: nexstar_secret_key_123' \
|
||||
--data-binary @/path/to/composicoes_produtos_YYYY-MM-DD.json
|
||||
```
|
||||
* Expected response includes `imported`, `failed`, `componentCount`, `referenceCount`, `skippedReferenceCount`, and `issues`.
|
||||
|
||||
## 9. Coding Standards & AI Directives
|
||||
* **Strict Type Safety:** Use explicit TypeScript interfaces (defined in `types.ts`). Avoid `any` where possible. Do not bypass type checks with `// @ts-ignore`.
|
||||
* **Idiomatic React:** Use functional components and hooks (`useState`, `useEffect`, `useMemo`). Complex data transformations (like merging arrays into chart-ready datasets) MUST be wrapped in `useMemo` to prevent unnecessary re-renders.
|
||||
* **Tailwind Architecture:** All styling must be handled via Tailwind CSS utility classes. Avoid custom CSS files unless defining global font families or root variables in `index.css`.
|
||||
@@ -141,7 +208,7 @@ The local Docker services use `restart: unless-stopped`, so containers should co
|
||||
* **API Security:** All backend modifications exposing or altering data MUST use the `verifyToken` middleware for frontend requests or `authenticateAPIKey` for external n8n webhooks.
|
||||
* **Build Discipline:** After frontend/backend behavior changes, run `npm run lint`, `npm run build`, and relevant backend tests. The user prefers builds after changes to catch issues before deployment.
|
||||
|
||||
## 9. Recent RFV Work
|
||||
## 10. Recent Work
|
||||
Recent commits related to RFV/date and client metadata behavior:
|
||||
* `4131bf9 Update RFV lifecycle to monthly windows` - RFV recency/lost thresholds changed to `0-7`, `8-15`, `16-29`, and `30+` lost.
|
||||
* `ef27cff Add seller performance dashboard charts` - dashboard now includes seller revenue/order charts.
|
||||
@@ -165,3 +232,26 @@ Files most relevant to RFV:
|
||||
* `src/components/DateRangePicker.tsx` and `src/dateRanges.ts` - local calendar date ranges and ISO query param formatting.
|
||||
* `src/dataService.ts` - analytics fetch/cache helpers and client filter option requests.
|
||||
* `src/displayFormatters.ts` - shared seller/fantasy/client metadata display cleanup.
|
||||
|
||||
Recent commits related to supply/cutting/data-flow and chart readability:
|
||||
* `37d4a77 Import Tiny product compositions` - adds the Tiny/Olist composition JSON import flow, derived `tiny_structure` consumption references, app upload button, API-key import endpoint, and import tests.
|
||||
* `800eb97 Derive consumption references from Tiny OP sync` - Tiny OP composition sync now creates catalog products/materials and reusable consumption references from OP component lines.
|
||||
* `c07938c Add Tiny production order detail sync` - stores OP detail rows, components, steps, and observations from Tiny/Olist sync payloads.
|
||||
* `060b4da Connect supplies to project demand` - Suprimentos purchase needs now derive from project/order demand and stock, while missing consumption references are surfaced explicitly.
|
||||
* `bc05fb4 Add SKU edit actions` - product/cutting/replenishment/group tables gained compact SKU view/edit actions.
|
||||
* `63efb47 Route SKU actions to focused editors` - cut-plan edit opens SKU cutting configuration; missing-reference supply rows open the consumption reference flow.
|
||||
* `9489318 Improve chart readability and drilldowns` - dashboard/product charts gained date bucketing, cleaner unknown labels, click drill-downs, and product stock coverage context.
|
||||
* `ba9cc3a Fix large sold quantity icon layout` - product/group sold quantity cells handle large values without icon compression.
|
||||
* `f923489 Make dashboard trend line optional` - dashboard moving-average trend line is hidden by default and available through `Tendência`.
|
||||
|
||||
Files most relevant to supply/cutting/data-flow:
|
||||
* `src/pages/Supplies.tsx` - supply dashboard, receipts, inventory, movements, and purchase-need UI.
|
||||
* `src/pages/Cutting.tsx` - cut plan, cut settings drawer, SKU correction/deep-link flow.
|
||||
* `src/pages/Registrations.tsx` - local catalog, raw materials, finished products, and consumption references.
|
||||
* `src/catalogLinks.ts` - shared deep-link builders for SKU edit, cutting config, and consumption references.
|
||||
* `src/analytics/cutting.ts` - cut-family parsing, cut needs, stock coverage, and product override logic.
|
||||
* `src/pages/Products.tsx` and `src/pages/ProductGroupDetails.tsx` - product/group velocity, stock coverage, and high-volume display.
|
||||
* `src/chartUtils.ts` - shared date bucketing, moving average, unknown-label cleanup, and chart date helpers.
|
||||
* `backend/services/productionOrderService.js` - Tiny OP detail sync, Tiny/Olist structure import, composition storage, sellable structure filtering, and derived consumption-reference creation.
|
||||
* `backend/routes/productionOrderRoutes.js` and `backend/routes/catalogRoutes.js` - API-key script import and authenticated app import endpoints for composition JSON.
|
||||
* `backend/test/productCompositionService.test.js` - coverage for Tiny/Olist structure sync and bulk composition import behavior.
|
||||
|
||||
@@ -112,8 +112,17 @@ N8N_WHATSAPP_TRIGGER_URL
|
||||
ADMIN_EMAIL
|
||||
ADMIN_PASSWORD
|
||||
JWT_SECRET
|
||||
TURNSTILE_SITE_KEY
|
||||
TURNSTILE_SECRET
|
||||
```
|
||||
|
||||
`TURNSTILE_SECRET` enables backend CAPTCHA enforcement on `/api/login`. Set
|
||||
`TURNSTILE_SITE_KEY` with Cloudflare's public site key so the login page can load
|
||||
the verification widget at runtime.
|
||||
|
||||
The backend also accepts `VITE_TURNSTILE_SITE_KEY` as a site-key alias and
|
||||
`TURNSTILE_SECRET_KEY` as a secret alias for older deployments.
|
||||
|
||||
## Validation
|
||||
|
||||
```bash
|
||||
|
||||
@@ -1,5 +1,34 @@
|
||||
require('dotenv').config();
|
||||
|
||||
const TURNSTILE_KEY_MATCH = /[0-9]x[0-9A-Za-z_-]{20,}/;
|
||||
|
||||
const normalizeTurnstileValue = (value) => {
|
||||
if (typeof value !== 'string') return '';
|
||||
|
||||
const trimmedValue = value.trim();
|
||||
const keyMatch = trimmedValue.match(TURNSTILE_KEY_MATCH);
|
||||
if (keyMatch) return keyMatch[0];
|
||||
|
||||
return trimmedValue.replace(/^[\s"'`{[]+|[\s"'`}\]]+$/g, '');
|
||||
};
|
||||
|
||||
const firstEnvValue = (...values) => values
|
||||
.map(normalizeTurnstileValue)
|
||||
.find(Boolean) || '';
|
||||
|
||||
const TURNSTILE_SITE_KEY = firstEnvValue(
|
||||
process.env.TURNSTILE_SITE_KEY,
|
||||
process.env.TURNSTILE_SITEKEY,
|
||||
process.env.VITE_TURNSTILE_SITE_KEY,
|
||||
process.env.CLOUDFLARE_TURNSTILE_SITE_KEY
|
||||
);
|
||||
|
||||
const TURNSTILE_SECRET = firstEnvValue(
|
||||
process.env.TURNSTILE_SECRET,
|
||||
process.env.TURNSTILE_SECRET_KEY,
|
||||
process.env.CLOUDFLARE_TURNSTILE_SECRET
|
||||
);
|
||||
|
||||
module.exports = {
|
||||
PORT: process.env.PORT || 3004,
|
||||
API_KEY: process.env.API_KEY || 'nexstar_secret_key_123',
|
||||
@@ -7,5 +36,7 @@ module.exports = {
|
||||
ADMIN_PASSWORD: process.env.ADMIN_PASSWORD || 'admin123',
|
||||
JWT_SECRET: process.env.JWT_SECRET || 'super_secret_jwt_key_123',
|
||||
DATABASE_URL: process.env.DATABASE_URL || 'postgres://graphuser:graphpassword@localhost:5432/graphdb',
|
||||
N8N_WHATSAPP_TRIGGER_URL: process.env.N8N_WHATSAPP_TRIGGER_URL || 'http://localhost:5678/webhook/whatsapp'
|
||||
N8N_WHATSAPP_TRIGGER_URL: process.env.N8N_WHATSAPP_TRIGGER_URL || 'http://localhost:5678/webhook/whatsapp',
|
||||
TURNSTILE_SITE_KEY,
|
||||
TURNSTILE_SECRET
|
||||
};
|
||||
|
||||
159
backend/db.js
159
backend/db.js
@@ -5,6 +5,29 @@ const pool = new Pool({
|
||||
connectionString: DATABASE_URL
|
||||
});
|
||||
|
||||
const defaultCatalogCategories = [
|
||||
['Camiseta regular', 'Bases lisas e camisetas adultas.'],
|
||||
['Camiseta infantil', 'Produtos infantis por cor e tamanho.'],
|
||||
['Moletom', 'Moletons, cangurus e produtos de frio.'],
|
||||
['Oversized', 'Modelagens oversized e variações relacionadas.'],
|
||||
['Acessórios', 'Bonés, itens complementares e produtos não têxteis.'],
|
||||
['DTF', 'Insumos e serviços relacionados a impressão DTF.'],
|
||||
['Malha', 'Tecidos e malhas usados como matéria-prima.'],
|
||||
['Aviamentos', 'Ribanas, linhas, ilhós e componentes de costura.'],
|
||||
['Embalagens', 'Sacos, etiquetas, tags e materiais de expedição.'],
|
||||
['Insumos gerais', 'Materiais de apoio sem família operacional específica.']
|
||||
];
|
||||
|
||||
const seedDefaultCatalogCategories = async () => {
|
||||
for (const [name, description] of defaultCatalogCategories) {
|
||||
await pool.query(`
|
||||
INSERT INTO catalog_categories (name, description, updated_at)
|
||||
VALUES ($1, $2, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT (name) DO NOTHING;
|
||||
`, [name, description]);
|
||||
}
|
||||
};
|
||||
|
||||
const initDB = async () => {
|
||||
try {
|
||||
await pool.query(`SET TIME ZONE 'America/Sao_Paulo';`);
|
||||
@@ -105,6 +128,13 @@ const initDB = async () => {
|
||||
quantity NUMERIC(14, 4) DEFAULT 0,
|
||||
unit VARCHAR(20) DEFAULT 'UN',
|
||||
integration_status VARCHAR(100),
|
||||
notes TEXT,
|
||||
supplier TEXT,
|
||||
lot_code VARCHAR(120),
|
||||
roll_quantity NUMERIC(14, 4),
|
||||
fabric_kg NUMERIC(14, 4),
|
||||
rib_kg NUMERIC(14, 4),
|
||||
yield_pieces_per_kg NUMERIC(14, 4),
|
||||
tiny_payload JSONB,
|
||||
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
|
||||
@@ -122,6 +152,70 @@ const initDB = async () => {
|
||||
);
|
||||
`);
|
||||
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS production_order_components (
|
||||
id SERIAL PRIMARY KEY,
|
||||
production_order_id INTEGER NOT NULL REFERENCES production_orders(id) ON DELETE CASCADE,
|
||||
component_tiny_id VARCHAR(100),
|
||||
component_sku VARCHAR(255),
|
||||
component_name TEXT NOT NULL,
|
||||
quantity_per_unit NUMERIC(14, 4) DEFAULT 0,
|
||||
total_quantity NUMERIC(14, 4) DEFAULT 0,
|
||||
unit VARCHAR(30),
|
||||
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
`);
|
||||
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS production_order_steps (
|
||||
id SERIAL PRIMARY KEY,
|
||||
production_order_id INTEGER NOT NULL REFERENCES production_orders(id) ON DELETE CASCADE,
|
||||
step_number INTEGER,
|
||||
name VARCHAR(160) NOT NULL,
|
||||
start_date DATE,
|
||||
end_date DATE,
|
||||
status VARCHAR(80),
|
||||
color VARCHAR(40),
|
||||
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
`);
|
||||
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS product_compositions (
|
||||
id SERIAL PRIMARY KEY,
|
||||
source VARCHAR(60) NOT NULL,
|
||||
external_source_id VARCHAR(120) NOT NULL,
|
||||
finished_product_identity VARCHAR(255) NOT NULL,
|
||||
finished_product_sku VARCHAR(255),
|
||||
finished_product_description TEXT NOT NULL,
|
||||
finished_product_unit VARCHAR(30) NOT NULL DEFAULT 'UN',
|
||||
finished_tiny_product_id VARCHAR(100),
|
||||
source_metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
last_synced_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE (source, finished_product_identity)
|
||||
);
|
||||
`);
|
||||
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS product_composition_components (
|
||||
id SERIAL PRIMARY KEY,
|
||||
product_composition_id INTEGER NOT NULL REFERENCES product_compositions(id) ON DELETE CASCADE,
|
||||
component_identity VARCHAR(255) NOT NULL,
|
||||
component_tiny_id VARCHAR(100),
|
||||
component_sku VARCHAR(255),
|
||||
component_name TEXT NOT NULL,
|
||||
quantity_per_unit NUMERIC(14, 4) NOT NULL DEFAULT 0,
|
||||
unit VARCHAR(30),
|
||||
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE (product_composition_id, component_identity)
|
||||
);
|
||||
`);
|
||||
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS cutting_family_rules (
|
||||
family_key VARCHAR(20) PRIMARY KEY,
|
||||
@@ -136,6 +230,8 @@ const initDB = async () => {
|
||||
family_key VARCHAR(20),
|
||||
color VARCHAR(100),
|
||||
size VARCHAR(40),
|
||||
product_type VARCHAR(40),
|
||||
planning_notes TEXT,
|
||||
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
`);
|
||||
@@ -183,6 +279,10 @@ const initDB = async () => {
|
||||
efficiency_percent NUMERIC(7, 3),
|
||||
rib_g_per_piece NUMERIC(14, 4),
|
||||
material_cost_per_kg NUMERIC(14, 4),
|
||||
consumption_quantity NUMERIC(14, 4),
|
||||
consumption_unit VARCHAR(30),
|
||||
source VARCHAR(60) DEFAULT 'manual',
|
||||
last_production_order_id INTEGER REFERENCES production_orders(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
@@ -252,6 +352,33 @@ const initDB = async () => {
|
||||
|
||||
await pool.query(`
|
||||
ALTER TABLE production_orders
|
||||
ADD COLUMN IF NOT EXISTS notes TEXT,
|
||||
ADD COLUMN IF NOT EXISTS supplier TEXT,
|
||||
ADD COLUMN IF NOT EXISTS lot_code VARCHAR(120),
|
||||
ADD COLUMN IF NOT EXISTS roll_quantity NUMERIC(14, 4),
|
||||
ADD COLUMN IF NOT EXISTS fabric_kg NUMERIC(14, 4),
|
||||
ADD COLUMN IF NOT EXISTS rib_kg NUMERIC(14, 4),
|
||||
ADD COLUMN IF NOT EXISTS yield_pieces_per_kg NUMERIC(14, 4);
|
||||
`).catch(() => {});
|
||||
|
||||
await pool.query(`
|
||||
ALTER TABLE production_orders
|
||||
ALTER COLUMN created_at TYPE TIMESTAMPTZ USING created_at AT TIME ZONE 'America/Sao_Paulo',
|
||||
ALTER COLUMN created_at SET DEFAULT CURRENT_TIMESTAMP,
|
||||
ALTER COLUMN updated_at TYPE TIMESTAMPTZ USING updated_at AT TIME ZONE 'America/Sao_Paulo',
|
||||
ALTER COLUMN updated_at SET DEFAULT CURRENT_TIMESTAMP;
|
||||
`).catch(() => {});
|
||||
|
||||
await pool.query(`
|
||||
ALTER TABLE production_order_components
|
||||
ALTER COLUMN created_at TYPE TIMESTAMPTZ USING created_at AT TIME ZONE 'America/Sao_Paulo',
|
||||
ALTER COLUMN created_at SET DEFAULT CURRENT_TIMESTAMP,
|
||||
ALTER COLUMN updated_at TYPE TIMESTAMPTZ USING updated_at AT TIME ZONE 'America/Sao_Paulo',
|
||||
ALTER COLUMN updated_at SET DEFAULT CURRENT_TIMESTAMP;
|
||||
`).catch(() => {});
|
||||
|
||||
await pool.query(`
|
||||
ALTER TABLE production_order_steps
|
||||
ALTER COLUMN created_at TYPE TIMESTAMPTZ USING created_at AT TIME ZONE 'America/Sao_Paulo',
|
||||
ALTER COLUMN created_at SET DEFAULT CURRENT_TIMESTAMP,
|
||||
ALTER COLUMN updated_at TYPE TIMESTAMPTZ USING updated_at AT TIME ZONE 'America/Sao_Paulo',
|
||||
@@ -270,6 +397,12 @@ const initDB = async () => {
|
||||
ALTER COLUMN updated_at SET DEFAULT CURRENT_TIMESTAMP;
|
||||
`).catch(() => {});
|
||||
|
||||
await pool.query(`
|
||||
ALTER TABLE cutting_product_overrides
|
||||
ADD COLUMN IF NOT EXISTS product_type VARCHAR(40),
|
||||
ADD COLUMN IF NOT EXISTS planning_notes TEXT;
|
||||
`).catch(() => {});
|
||||
|
||||
await pool.query(`
|
||||
ALTER TABLE catalog_categories
|
||||
ALTER COLUMN created_at TYPE TIMESTAMPTZ USING created_at AT TIME ZONE 'America/Sao_Paulo',
|
||||
@@ -286,6 +419,14 @@ const initDB = async () => {
|
||||
ALTER COLUMN updated_at SET DEFAULT CURRENT_TIMESTAMP;
|
||||
`).catch(() => {});
|
||||
|
||||
await pool.query(`
|
||||
ALTER TABLE consumption_references
|
||||
ADD COLUMN IF NOT EXISTS consumption_quantity NUMERIC(14, 4),
|
||||
ADD COLUMN IF NOT EXISTS consumption_unit VARCHAR(30),
|
||||
ADD COLUMN IF NOT EXISTS source VARCHAR(60) DEFAULT 'manual',
|
||||
ADD COLUMN IF NOT EXISTS last_production_order_id INTEGER REFERENCES production_orders(id) ON DELETE SET NULL;
|
||||
`).catch(() => {});
|
||||
|
||||
await pool.query(`
|
||||
ALTER TABLE consumption_references
|
||||
ALTER COLUMN created_at TYPE TIMESTAMPTZ USING created_at AT TIME ZONE 'America/Sao_Paulo',
|
||||
@@ -294,6 +435,8 @@ const initDB = async () => {
|
||||
ALTER COLUMN updated_at SET DEFAULT CURRENT_TIMESTAMP;
|
||||
`).catch(() => {});
|
||||
|
||||
await seedDefaultCatalogCategories();
|
||||
|
||||
await pool.query(`
|
||||
ALTER TABLE supply_receipts
|
||||
ALTER COLUMN created_at TYPE TIMESTAMPTZ USING created_at AT TIME ZONE 'America/Sao_Paulo',
|
||||
@@ -392,11 +535,27 @@ const initDB = async () => {
|
||||
await pool.query(`CREATE INDEX IF NOT EXISTS idx_production_orders_issue_date ON production_orders (issue_date DESC);`);
|
||||
await pool.query(`CREATE INDEX IF NOT EXISTS idx_production_orders_expected_date ON production_orders (expected_date DESC);`);
|
||||
await pool.query(`CREATE INDEX IF NOT EXISTS idx_production_order_markers_order_id ON production_order_markers (production_order_id);`);
|
||||
await pool.query(`CREATE INDEX IF NOT EXISTS idx_production_order_components_order_id ON production_order_components (production_order_id);`);
|
||||
await pool.query(`CREATE INDEX IF NOT EXISTS idx_production_order_components_sku ON production_order_components (component_sku);`);
|
||||
await pool.query(`CREATE INDEX IF NOT EXISTS idx_production_order_steps_order_id ON production_order_steps (production_order_id);`);
|
||||
await pool.query(`CREATE INDEX IF NOT EXISTS idx_product_compositions_finished_tiny_id ON product_compositions (finished_tiny_product_id);`);
|
||||
await pool.query(`CREATE INDEX IF NOT EXISTS idx_product_composition_components_tiny_id ON product_composition_components (component_tiny_id);`);
|
||||
await pool.query(`CREATE INDEX IF NOT EXISTS idx_cutting_product_overrides_family_key ON cutting_product_overrides (family_key);`);
|
||||
await pool.query(`CREATE INDEX IF NOT EXISTS idx_catalog_products_type ON catalog_products (type);`);
|
||||
await pool.query(`CREATE INDEX IF NOT EXISTS idx_catalog_products_category_id ON catalog_products (category_id);`);
|
||||
await pool.query(`CREATE INDEX IF NOT EXISTS idx_consumption_references_product_id ON consumption_references (product_id);`);
|
||||
await pool.query(`CREATE INDEX IF NOT EXISTS idx_consumption_references_material_product_id ON consumption_references (material_product_id);`);
|
||||
await pool.query(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS unique_consumption_reference_source
|
||||
ON consumption_references (
|
||||
product_id,
|
||||
COALESCE(material_product_id, 0),
|
||||
COALESCE(color, ''),
|
||||
COALESCE(source, 'manual')
|
||||
);
|
||||
`).catch(err => {
|
||||
console.error('Notice: Could not create unique consumption reference source index:', err.message);
|
||||
});
|
||||
await pool.query(`CREATE INDEX IF NOT EXISTS idx_supply_receipts_status ON supply_receipts (status);`);
|
||||
await pool.query(`CREATE INDEX IF NOT EXISTS idx_supply_receipts_created_at ON supply_receipts (created_at DESC);`);
|
||||
await pool.query(`CREATE INDEX IF NOT EXISTS idx_supply_stock_lots_status ON supply_stock_lots (status);`);
|
||||
|
||||
@@ -6,10 +6,13 @@ const {
|
||||
getClientFilterOptions,
|
||||
getClientPurchasePatternAnalytics,
|
||||
getDashboardAnalytics,
|
||||
getSellerAnalytics,
|
||||
getSellerDetailsAnalytics,
|
||||
getProductAnalytics,
|
||||
getProductDetailsAnalytics,
|
||||
getRfmAnalytics
|
||||
} = require('../services/analyticsService');
|
||||
const { getProductComposition, listProductCompositions } = require('../services/productionOrderService');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
@@ -34,6 +37,29 @@ router.get('/analytics/dashboard', verifyToken, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/analytics/sellers', verifyToken, async (req, res) => {
|
||||
try {
|
||||
res.json(await getSellerAnalytics(getRange(req.query)));
|
||||
} catch (error) {
|
||||
console.error('Error fetching seller analytics:', error);
|
||||
res.status(500).json({ error: 'Internal Server Error' });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/analytics/sellers/:sellerId/details', verifyToken, async (req, res) => {
|
||||
try {
|
||||
const details = await getSellerDetailsAnalytics(req.params.sellerId, getRange(req.query));
|
||||
if (!details) {
|
||||
res.status(404).json({ error: 'Seller not found' });
|
||||
return;
|
||||
}
|
||||
res.json(details);
|
||||
} catch (error) {
|
||||
console.error('Error fetching seller details analytics:', error);
|
||||
res.status(500).json({ error: 'Internal Server Error' });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/analytics/products', verifyToken, async (req, res) => {
|
||||
try {
|
||||
res.json(await getProductAnalytics(getRange(req.query)));
|
||||
@@ -58,6 +84,25 @@ router.get('/analytics/products/:productId/details', verifyToken, async (req, re
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/analytics/products/:productId/composition', verifyToken, async (req, res) => {
|
||||
try {
|
||||
const composition = await getProductComposition(req.params.productId);
|
||||
res.json({ composition });
|
||||
} catch (error) {
|
||||
console.error('Error fetching product composition:', error);
|
||||
res.status(500).json({ error: 'Internal Server Error' });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/analytics/product-compositions', verifyToken, async (req, res) => {
|
||||
try {
|
||||
res.json({ compositions: await listProductCompositions() });
|
||||
} catch (error) {
|
||||
console.error('Error exporting product compositions:', error);
|
||||
res.status(500).json({ error: 'Internal Server Error' });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/analytics/clients', verifyToken, async (req, res) => {
|
||||
try {
|
||||
res.json(await getClientAnalytics(getClientAnalyticsFilters(req.query)));
|
||||
|
||||
@@ -1,12 +1,59 @@
|
||||
const express = require('express');
|
||||
const { login } = require('../auth');
|
||||
const { TURNSTILE_SECRET, TURNSTILE_SITE_KEY } = require('../config');
|
||||
|
||||
const router = express.Router();
|
||||
const TURNSTILE_VERIFY_URL = 'https://challenges.cloudflare.com/turnstile/v0/siteverify';
|
||||
const TURNSTILE_SITE_KEY_PATTERN = /^[0-9]x[0-9A-Za-z_-]{20,}$/;
|
||||
const hasValidTurnstileSiteKey = TURNSTILE_SITE_KEY_PATTERN.test(TURNSTILE_SITE_KEY);
|
||||
|
||||
router.post('/login', async (req, res, next) => {
|
||||
const { email, password } = req.body;
|
||||
const verifyCaptcha = async (captchaToken, remoteIp) => {
|
||||
if (!TURNSTILE_SECRET) return true;
|
||||
if (!captchaToken || typeof captchaToken !== 'string') return false;
|
||||
|
||||
try {
|
||||
const formData = new URLSearchParams({
|
||||
secret: TURNSTILE_SECRET,
|
||||
response: captchaToken
|
||||
});
|
||||
|
||||
if (remoteIp) {
|
||||
formData.set('remoteip', remoteIp);
|
||||
}
|
||||
|
||||
const response = await fetch(TURNSTILE_VERIFY_URL, {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
if (!response.ok) return false;
|
||||
|
||||
const result = await response.json();
|
||||
return result.success === true;
|
||||
} catch (error) {
|
||||
console.error('Captcha verification failed', error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
router.get('/login/config', (req, res) => {
|
||||
res.json({
|
||||
captchaRequired: Boolean(TURNSTILE_SECRET),
|
||||
turnstileSiteKey: hasValidTurnstileSiteKey ? TURNSTILE_SITE_KEY : '',
|
||||
captchaConfigured: Boolean(TURNSTILE_SECRET && hasValidTurnstileSiteKey)
|
||||
});
|
||||
});
|
||||
|
||||
router.post('/login', async (req, res, next) => {
|
||||
const { email, password, captchaToken } = req.body;
|
||||
|
||||
try {
|
||||
const captchaValid = await verifyCaptcha(captchaToken, req.ip);
|
||||
if (!captchaValid) {
|
||||
res.status(403).json({ error: 'Captcha verification failed' });
|
||||
return;
|
||||
}
|
||||
|
||||
const authResult = await login(email, password);
|
||||
|
||||
if (!authResult) {
|
||||
|
||||
@@ -1,14 +1,24 @@
|
||||
const express = require('express');
|
||||
const { verifyToken } = require('../auth');
|
||||
const { authenticateAPIKey, verifyToken } = require('../auth');
|
||||
const {
|
||||
getCampaignPreview,
|
||||
getCampaignQueueSummary,
|
||||
getTopClientsForCampaign,
|
||||
processPendingStockCampaigns,
|
||||
retryCampaignItems
|
||||
} = require('../services/campaignService');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
const verifyCampaignExportAccess = (req, res, next) => {
|
||||
if (req.headers['x-api-key']) {
|
||||
authenticateAPIKey(req, res, next);
|
||||
return;
|
||||
}
|
||||
|
||||
verifyToken(req, res, next);
|
||||
};
|
||||
|
||||
router.get('/campaigns', verifyToken, async (req, res) => {
|
||||
try {
|
||||
res.json(await getCampaignQueueSummary());
|
||||
@@ -27,6 +37,15 @@ router.get('/campaigns/preview', verifyToken, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/campaigns/top-clients', verifyCampaignExportAccess, async (req, res) => {
|
||||
try {
|
||||
res.json(await getTopClientsForCampaign(req.query || {}));
|
||||
} catch (error) {
|
||||
console.error('Error fetching top campaign clients:', error);
|
||||
res.status(500).json({ error: 'Internal Server Error' });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/campaigns/process', verifyToken, async (req, res) => {
|
||||
try {
|
||||
res.json(await processPendingStockCampaigns());
|
||||
|
||||
@@ -12,6 +12,7 @@ const {
|
||||
listConsumptionReferences,
|
||||
listProducts
|
||||
} = require('../services/catalogService');
|
||||
const { importTinyProductCompositionExport } = require('../services/productionOrderService');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
@@ -89,6 +90,14 @@ router.post('/catalog/consumption-references', verifyToken, async (req, res, nex
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/catalog/product-compositions/import', verifyToken, async (req, res, next) => {
|
||||
try {
|
||||
res.status(201).json(await importTinyProductCompositionExport(req.body || {}));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.delete('/catalog/consumption-references/:id', verifyToken, async (req, res, next) => {
|
||||
try {
|
||||
await deleteConsumptionReference(req.params.id);
|
||||
|
||||
15
backend/routes/databaseDiagnosticRoutes.js
Normal file
15
backend/routes/databaseDiagnosticRoutes.js
Normal 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;
|
||||
@@ -1,6 +1,6 @@
|
||||
const express = require('express');
|
||||
const { verifyToken } = require('../auth');
|
||||
const { listProductionOrders } = require('../services/productionOrderService');
|
||||
const { authenticateAPIKey, verifyToken } = require('../auth');
|
||||
const { createProductionOrders, importTinyProductCompositionExport, listProductionOrders, updateProductionOrderStatus, upsertTinyProductionOrderDetail } = require('../services/productionOrderService');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
@@ -13,4 +13,41 @@ router.get('/production-orders', verifyToken, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/production-orders', verifyToken, async (req, res) => {
|
||||
try {
|
||||
const orders = Array.isArray(req.body?.orders) ? req.body.orders : [];
|
||||
res.status(201).json(await createProductionOrders(orders));
|
||||
} catch (error) {
|
||||
console.error('Error creating production orders:', error);
|
||||
res.status(error.statusCode || 500).json({ error: error.message || 'Internal Server Error' });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/production-orders/tiny-sync', authenticateAPIKey, async (req, res) => {
|
||||
try {
|
||||
res.status(201).json(await upsertTinyProductionOrderDetail(req.body || {}));
|
||||
} catch (error) {
|
||||
console.error('Error syncing Tiny production order:', error);
|
||||
res.status(error.statusCode || 500).json({ error: error.message || 'Internal Server Error' });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/production-orders/tiny-compositions/import', authenticateAPIKey, async (req, res) => {
|
||||
try {
|
||||
res.status(201).json(await importTinyProductCompositionExport(req.body || {}));
|
||||
} catch (error) {
|
||||
console.error('Error importing Tiny product compositions:', error);
|
||||
res.status(error.statusCode || 500).json({ error: error.message || 'Internal Server Error' });
|
||||
}
|
||||
});
|
||||
|
||||
router.patch('/production-orders/:id/status', verifyToken, async (req, res) => {
|
||||
try {
|
||||
res.json(await updateProductionOrderStatus(req.params.id, req.body?.status));
|
||||
} catch (error) {
|
||||
console.error('Error updating production order status:', error);
|
||||
res.status(error.statusCode || 500).json({ error: error.message || 'Internal Server Error' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -12,12 +12,13 @@ const productionOrderRoutes = require('./routes/productionOrderRoutes');
|
||||
const cuttingSettingsRoutes = require('./routes/cuttingSettingsRoutes');
|
||||
const catalogRoutes = require('./routes/catalogRoutes');
|
||||
const supplyRoutes = require('./routes/supplyRoutes');
|
||||
const databaseDiagnosticRoutes = require('./routes/databaseDiagnosticRoutes');
|
||||
|
||||
const createApp = () => {
|
||||
const app = express();
|
||||
|
||||
app.use(cors());
|
||||
app.use(bodyParser.json());
|
||||
app.use(bodyParser.json({ limit: '10mb' }));
|
||||
|
||||
app.use('/api', authRoutes);
|
||||
app.use('/api', dataRoutes);
|
||||
@@ -27,6 +28,7 @@ const createApp = () => {
|
||||
app.use('/api', cuttingSettingsRoutes);
|
||||
app.use('/api', catalogRoutes);
|
||||
app.use('/api', supplyRoutes);
|
||||
app.use('/api', databaseDiagnosticRoutes);
|
||||
app.use('/api', analyticsRoutes);
|
||||
app.use('/api', userRoutes);
|
||||
app.use('/api/internal', internalRoutes);
|
||||
|
||||
@@ -694,6 +694,119 @@ const getDashboardAnalytics = async (range = {}) => {
|
||||
};
|
||||
};
|
||||
|
||||
const getSellerAnalytics = async (range = {}) => {
|
||||
const { params, whereClause } = buildDateFilter(range);
|
||||
const result = await pool.query(`
|
||||
WITH seller_orders AS (
|
||||
SELECT
|
||||
COALESCE(${SELLER_ID_SQL}, 'name:' || ${SELLER_NAME_SQL}) as seller_id,
|
||||
COALESCE(${SELLER_NAME_SQL}, ${SELLER_ID_SQL}, 'Sem vendedor') as seller_name,
|
||||
quantidade,
|
||||
valor_unitario,
|
||||
pedido_id,
|
||||
data_pedido,
|
||||
valor_pedido,
|
||||
data_pedido_date,
|
||||
COALESCE(NULLIF(cliente_fone, ''), 'name:' || COALESCE(${NORMALIZED_CUSTOMER_NAME_SQL}, 'desconhecido')) as customer_key
|
||||
FROM orders
|
||||
${whereClause}
|
||||
AND (${SELLER_ID_SQL} IS NOT NULL OR ${SELLER_NAME_SQL} IS NOT NULL)
|
||||
)
|
||||
SELECT
|
||||
seller_id as id,
|
||||
MAX(seller_name) as name,
|
||||
COALESCE(SUM(quantidade * valor_unitario), 0) as revenue,
|
||||
COALESCE(SUM(quantidade), 0) as quantity_sold,
|
||||
COUNT(DISTINCT COALESCE(NULLIF(pedido_id, ''), data_pedido || '_' || valor_pedido::text))::int as order_count,
|
||||
COUNT(DISTINCT customer_key)::int as customer_count,
|
||||
MAX(data_pedido_date) as last_order_date
|
||||
FROM seller_orders
|
||||
GROUP BY seller_id
|
||||
ORDER BY revenue DESC, order_count DESC, name ASC;
|
||||
`, params);
|
||||
|
||||
return result.rows.map(row => ({
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
revenue: toNumber(row.revenue),
|
||||
quantitySold: toNumber(row.quantity_sold),
|
||||
orderCount: toNumber(row.order_count),
|
||||
customerCount: toNumber(row.customer_count),
|
||||
lastOrderDate: row.last_order_date,
|
||||
averageTicket: toNumber(row.order_count) ? toNumber(row.revenue) / toNumber(row.order_count) : 0
|
||||
}));
|
||||
};
|
||||
|
||||
const getSellerDetailsAnalytics = async (sellerId, range = {}) => {
|
||||
const seller = normalizeSellerFilter(sellerId);
|
||||
if (!seller) return null;
|
||||
|
||||
const sellerRange = { ...range, seller: sellerId };
|
||||
const { params, whereClause } = buildDateFilter(sellerRange);
|
||||
const [summaryResult, trendResult, marketplaceResult, products, clients] = await Promise.all([
|
||||
pool.query(`
|
||||
SELECT
|
||||
COALESCE(MAX(${SELLER_NAME_SQL}), MAX(${SELLER_ID_SQL}), 'Sem vendedor') as name,
|
||||
COALESCE(SUM(quantidade * valor_unitario), 0) as revenue,
|
||||
COALESCE(SUM(quantidade), 0) as quantity_sold,
|
||||
COUNT(DISTINCT COALESCE(NULLIF(pedido_id, ''), data_pedido || '_' || valor_pedido::text))::int as order_count,
|
||||
MAX(data_pedido_date) as last_order_date
|
||||
FROM orders
|
||||
${whereClause};
|
||||
`, params),
|
||||
pool.query(`
|
||||
SELECT
|
||||
data_pedido_date::text as date,
|
||||
COALESCE(SUM(quantidade * valor_unitario), 0) as revenue,
|
||||
COALESCE(SUM(quantidade), 0) as quantity_sold,
|
||||
COUNT(DISTINCT COALESCE(NULLIF(pedido_id, ''), data_pedido || '_' || valor_pedido::text))::int as order_count
|
||||
FROM orders
|
||||
${whereClause}
|
||||
GROUP BY data_pedido_date
|
||||
ORDER BY data_pedido_date ASC;
|
||||
`, params),
|
||||
pool.query(`
|
||||
SELECT
|
||||
COALESCE(NULLIF(TRIM(marketplace), ''), 'Sem marketplace') as name,
|
||||
COALESCE(SUM(quantidade * valor_unitario), 0) as revenue,
|
||||
COUNT(DISTINCT COALESCE(NULLIF(pedido_id, ''), data_pedido || '_' || valor_pedido::text))::int as order_count
|
||||
FROM orders
|
||||
${whereClause}
|
||||
GROUP BY name
|
||||
ORDER BY revenue DESC
|
||||
LIMIT 6;
|
||||
`, params),
|
||||
getProductAnalytics(sellerRange),
|
||||
getClientAnalytics(sellerRange)
|
||||
]);
|
||||
|
||||
const summary = summaryResult.rows[0];
|
||||
if (!summary || (!toNumber(summary.revenue) && !toNumber(summary.order_count))) return null;
|
||||
const orderCount = toNumber(summary.order_count);
|
||||
|
||||
return {
|
||||
seller: {
|
||||
id: String(sellerId),
|
||||
name: summary.name,
|
||||
revenue: toNumber(summary.revenue),
|
||||
quantitySold: toNumber(summary.quantity_sold),
|
||||
orderCount,
|
||||
customerCount: clients.length,
|
||||
lastOrderDate: summary.last_order_date,
|
||||
averageTicket: orderCount ? toNumber(summary.revenue) / orderCount : 0
|
||||
},
|
||||
chartData: trendResult.rows.map(row => ({
|
||||
date: row.date,
|
||||
revenue: toNumber(row.revenue),
|
||||
quantitySold: toNumber(row.quantity_sold),
|
||||
orderCount: toNumber(row.order_count)
|
||||
})),
|
||||
topProducts: products.slice(0, 8),
|
||||
topClients: clients.slice(0, 8),
|
||||
marketplaces: marketplaceResult.rows.map(row => ({ name: row.name, revenue: toNumber(row.revenue), orderCount: toNumber(row.order_count) }))
|
||||
};
|
||||
};
|
||||
|
||||
const getProductAnalytics = async (range = {}) => {
|
||||
const { params, whereClause } = buildDateFilter(range);
|
||||
const result = await pool.query(`
|
||||
@@ -1533,6 +1646,8 @@ module.exports = {
|
||||
getRfmSegment,
|
||||
getClientAnalytics,
|
||||
getDashboardAnalytics,
|
||||
getSellerAnalytics,
|
||||
getSellerDetailsAnalytics,
|
||||
getProductDetailsAnalytics,
|
||||
getProductAnalytics,
|
||||
normalizeDateParam,
|
||||
|
||||
@@ -6,6 +6,26 @@ const applyProductDisplayAlias = (name) => {
|
||||
.replace(/^BASE LISA MOLETOM CANGURU\b/i, 'MOLETOM CANGURU PREMIUM');
|
||||
};
|
||||
|
||||
const normalizeCampaignProductName = (name) => String(name || '')
|
||||
.normalize('NFD')
|
||||
.replace(/\p{Diacritic}/gu, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
.toUpperCase();
|
||||
|
||||
const isCampaignEligibleProductName = (name) => {
|
||||
const normalizedName = normalizeCampaignProductName(name);
|
||||
if (!normalizedName) return false;
|
||||
|
||||
if (/^(?:\d+(?:\.\d+)?\s+)?(?:MALHA|RIBANA)\b/.test(normalizedName)) return false;
|
||||
if (/\b(?:TINTA|FILME|POLIAMIDA|PO PARA DTF|ROLO DTF|FLUIDO|PRIMER|CABECA|SENSOR|FILTRO|BOMBA)\b.*\bDTF\b/.test(normalizedName)) return false;
|
||||
if (/\b(?:MALHA|RIBANA|ATACADOR|ILHOS|ETIQUETA|TAG|FITA|LINHA PARA COSTURA|FIO|TECIDO|RETALHO|RESIDUO)\b/.test(normalizedName)) return false;
|
||||
if (/\b(?:SALDO ESTOQUE|FRETE|SERVICO|TRANSPORTE|TECELAGEM|TINTURARIA)\b/.test(normalizedName)) return false;
|
||||
if (/\b(?:PRENSA|MAQUINA|OVERLOCK|OVERLOK|GALONEIRA|PRATELEIRA|CABO FLAT|WIPPER|PRIMER|FLUIDO|SENSOR|FILTRO)\b/.test(normalizedName)) return false;
|
||||
|
||||
return /\b(?:DTF|CAMISETA|CAMISA|MOLETOM|CANGURU|REGATA|OVERSIZE|OVER SIZE)\b/.test(normalizedName);
|
||||
};
|
||||
|
||||
const formatProductNameForDisplay = (name) => {
|
||||
return applyProductDisplayAlias(name)
|
||||
.toLocaleLowerCase('pt-BR')
|
||||
@@ -103,5 +123,6 @@ module.exports = {
|
||||
formatProductList,
|
||||
groupCampaignRows,
|
||||
groupCampaignRowsByBaseProduct,
|
||||
isCampaignEligibleProductName,
|
||||
mapCampaignProducts
|
||||
};
|
||||
|
||||
@@ -5,14 +5,118 @@ const {
|
||||
formatProductList,
|
||||
groupCampaignRows,
|
||||
groupCampaignRowsByBaseProduct,
|
||||
isCampaignEligibleProductName,
|
||||
mapCampaignProducts
|
||||
} = require('./campaignFormatter');
|
||||
|
||||
const TOP_BUYERS_LIMIT = 100;
|
||||
const TOP_CLIENTS_DEFAULT_DAYS = 30;
|
||||
const TOP_CLIENTS_DEFAULT_LIMIT = 1000;
|
||||
const TOP_CLIENTS_MAX_LIMIT = 5000;
|
||||
const MAX_CAMPAIGN_ATTEMPTS = 3;
|
||||
const CAMPAIGN_DELTA_THRESHOLD = 100;
|
||||
const SAO_PAULO_TIME_ZONE = 'America/Sao_Paulo';
|
||||
const NORMALIZED_CUSTOMER_NAME_SQL = "NULLIF(LOWER(TRIM(regexp_replace(COALESCE(cliente_nome, ''), '\\s+', ' ', 'g'))), '')";
|
||||
const NORMALIZED_CUSTOMER_PHONE_SQL = "NULLIF(regexp_replace(COALESCE(cliente_fone, ''), '\\D', '', 'g'), '')";
|
||||
const WHATSAPP_CUSTOMER_PHONE_SQL = `
|
||||
CASE
|
||||
WHEN ${NORMALIZED_CUSTOMER_PHONE_SQL} LIKE '55%' THEN ${NORMALIZED_CUSTOMER_PHONE_SQL}
|
||||
WHEN length(${NORMALIZED_CUSTOMER_PHONE_SQL}) IN (10, 11) THEN '55' || ${NORMALIZED_CUSTOMER_PHONE_SQL}
|
||||
ELSE ${NORMALIZED_CUSTOMER_PHONE_SQL}
|
||||
END
|
||||
`;
|
||||
const CUSTOMER_IDENTITY_CTE = `
|
||||
WITH customer_phone_by_name AS (
|
||||
SELECT
|
||||
${NORMALIZED_CUSTOMER_NAME_SQL} as normalized_customer_name,
|
||||
(ARRAY_AGG(NULLIF(cliente_fone, '') ORDER BY data_pedido_date DESC NULLS LAST, id DESC)
|
||||
)[1] as canonical_phone
|
||||
FROM orders
|
||||
WHERE NULLIF(cliente_fone, '') IS NOT NULL
|
||||
AND ${NORMALIZED_CUSTOMER_NAME_SQL} IS NOT NULL
|
||||
GROUP BY normalized_customer_name
|
||||
),
|
||||
identity_orders AS (
|
||||
SELECT
|
||||
orders.*,
|
||||
${NORMALIZED_CUSTOMER_NAME_SQL} as normalized_customer_name,
|
||||
COALESCE(
|
||||
NULLIF(orders.cliente_fone, ''),
|
||||
customer_phone_by_name.canonical_phone,
|
||||
'name:' || COALESCE(NULLIF(orders.cliente_nome, ''), 'Cliente Desconhecido')
|
||||
) as customer_key
|
||||
FROM orders
|
||||
LEFT JOIN customer_phone_by_name
|
||||
ON customer_phone_by_name.normalized_customer_name = ${NORMALIZED_CUSTOMER_NAME_SQL}
|
||||
)
|
||||
`;
|
||||
|
||||
const normalizeDateParam = (value) => {
|
||||
if (!value) return null;
|
||||
|
||||
const match = String(value).trim().match(/^(\d{4})-(\d{2})-(\d{2})$/);
|
||||
if (!match) return null;
|
||||
|
||||
const [, yearValue, monthValue, dayValue] = match;
|
||||
const year = Number(yearValue);
|
||||
const month = Number(monthValue);
|
||||
const day = Number(dayValue);
|
||||
const date = new Date(Date.UTC(year, month - 1, day));
|
||||
|
||||
if (
|
||||
date.getUTCFullYear() !== year ||
|
||||
date.getUTCMonth() !== month - 1 ||
|
||||
date.getUTCDate() !== day
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return `${yearValue}-${monthValue}-${dayValue}`;
|
||||
};
|
||||
|
||||
const parsePositiveInteger = (value, defaultValue, maxValue) => {
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
if (!Number.isFinite(parsed) || parsed < 1) return defaultValue;
|
||||
return Math.min(parsed, maxValue);
|
||||
};
|
||||
|
||||
const getDateStringInTimeZone = (date = new Date(), timeZone = SAO_PAULO_TIME_ZONE) => {
|
||||
const parts = new Intl.DateTimeFormat('en-US', {
|
||||
timeZone,
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit'
|
||||
}).formatToParts(date);
|
||||
const partMap = Object.fromEntries(parts.map(part => [part.type, part.value]));
|
||||
|
||||
return `${partMap.year}-${partMap.month}-${partMap.day}`;
|
||||
};
|
||||
|
||||
const subtractDaysFromDateString = (dateString, daysToSubtract) => {
|
||||
const [year, month, day] = dateString.split('-').map(Number);
|
||||
const date = new Date(Date.UTC(year, month - 1, day));
|
||||
date.setUTCDate(date.getUTCDate() - daysToSubtract);
|
||||
|
||||
return date.toISOString().slice(0, 10);
|
||||
};
|
||||
|
||||
const getTopClientsDateRange = ({ days = TOP_CLIENTS_DEFAULT_DAYS, start, end } = {}) => {
|
||||
const normalizedDays = parsePositiveInteger(days, TOP_CLIENTS_DEFAULT_DAYS, 3650);
|
||||
const normalizedEnd = normalizeDateParam(end) || getDateStringInTimeZone();
|
||||
const normalizedStart = normalizeDateParam(start) || subtractDaysFromDateString(normalizedEnd, normalizedDays - 1);
|
||||
|
||||
return {
|
||||
days: normalizedDays,
|
||||
start: normalizedStart,
|
||||
end: normalizedEnd
|
||||
};
|
||||
};
|
||||
|
||||
const enqueueStockCampaignItem = async (client, item) => {
|
||||
if (!isCampaignEligibleProductName(item.baseProductName || item.nome)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const query = `
|
||||
INSERT INTO stock_campaign_queue (
|
||||
base_product_name, produto_id, nome, saldo, delta_estoque
|
||||
@@ -26,6 +130,8 @@ const enqueueStockCampaignItem = async (client, item) => {
|
||||
item.saldo,
|
||||
item.deltaEstoque
|
||||
]);
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
const getTopBuyersAllTime = async () => {
|
||||
@@ -46,6 +152,63 @@ const getTopBuyersAllTime = async () => {
|
||||
return result.rows;
|
||||
};
|
||||
|
||||
const getTopClientsForCampaign = async ({ days, limit, start, end } = {}) => {
|
||||
const range = getTopClientsDateRange({ days, start, end });
|
||||
const normalizedLimit = parsePositiveInteger(limit, TOP_CLIENTS_DEFAULT_LIMIT, TOP_CLIENTS_MAX_LIMIT);
|
||||
const result = await pool.query(`
|
||||
WITH campaign_orders AS (
|
||||
SELECT
|
||||
orders.*,
|
||||
${WHATSAPP_CUSTOMER_PHONE_SQL} as whatsapp_phone
|
||||
FROM orders
|
||||
)
|
||||
SELECT
|
||||
(
|
||||
ARRAY_AGG(COALESCE(NULLIF(cliente_nome, ''), 'Cliente Desconhecido')
|
||||
ORDER BY data_pedido_date DESC NULLS LAST, id DESC)
|
||||
)[1] as nome,
|
||||
(
|
||||
ARRAY_AGG(whatsapp_phone ORDER BY data_pedido_date DESC NULLS LAST, id DESC)
|
||||
FILTER (WHERE whatsapp_phone IS NOT NULL)
|
||||
)[1] as fone,
|
||||
COALESCE(SUM(quantidade * valor_unitario), 0) as total_gasto,
|
||||
COALESCE(SUM(quantidade), 0) as total_comprado,
|
||||
COUNT(DISTINCT COALESCE(NULLIF(pedido_id, ''), data_pedido || '_' || valor_pedido::text))::int as total_pedidos,
|
||||
MAX(data_pedido_date) as ultima_compra,
|
||||
ARRAY_REMOVE(ARRAY_AGG(DISTINCT whatsapp_phone), NULL) as telefones
|
||||
FROM campaign_orders
|
||||
WHERE data_pedido_date >= $1::date
|
||||
AND data_pedido_date <= $2::date
|
||||
AND whatsapp_phone IS NOT NULL
|
||||
-- A campaign recipient is a WhatsApp destination, so each normalized
|
||||
-- phone number must produce exactly one exported customer.
|
||||
GROUP BY whatsapp_phone
|
||||
ORDER BY total_gasto DESC
|
||||
LIMIT $3;
|
||||
`, [range.start, range.end, normalizedLimit]);
|
||||
|
||||
const customers = result.rows.map(row => ({
|
||||
nome: row.nome,
|
||||
fone: row.fone,
|
||||
total_gasto: Number(row.total_gasto || 0),
|
||||
total_comprado: Number(row.total_comprado || 0),
|
||||
total_pedidos: Number(row.total_pedidos || 0),
|
||||
ultima_compra: row.ultima_compra,
|
||||
telefones: Array.isArray(row.telefones) ? row.telefones : []
|
||||
}));
|
||||
|
||||
return {
|
||||
campaign: 'top_clients',
|
||||
days: range.days,
|
||||
start: range.start,
|
||||
end: range.end,
|
||||
limit: normalizedLimit,
|
||||
count: customers.length,
|
||||
generated_at: new Date().toISOString(),
|
||||
customers
|
||||
};
|
||||
};
|
||||
|
||||
const claimReadyCampaignItems = async () => {
|
||||
const client = await pool.connect();
|
||||
|
||||
@@ -117,7 +280,8 @@ const getCampaignQueueRows = async () => {
|
||||
};
|
||||
|
||||
const getCampaignQueueSummary = async () => {
|
||||
const rows = await getCampaignQueueRows();
|
||||
const rows = (await getCampaignQueueRows())
|
||||
.filter(row => isCampaignEligibleProductName(row.base_product_name || row.nome));
|
||||
return {
|
||||
threshold: CAMPAIGN_DELTA_THRESHOLD,
|
||||
maxAttempts: MAX_CAMPAIGN_ATTEMPTS,
|
||||
@@ -134,7 +298,8 @@ const getCampaignPreview = async () => {
|
||||
AND attempts < $1
|
||||
ORDER BY created_at ASC, id ASC;
|
||||
`, [MAX_CAMPAIGN_ATTEMPTS]);
|
||||
const groups = groupCampaignRowsByBaseProduct(result.rows);
|
||||
const eligibleRows = result.rows.filter(row => isCampaignEligibleProductName(row.base_product_name || row.nome));
|
||||
const groups = groupCampaignRowsByBaseProduct(eligibleRows);
|
||||
const readyGroups = {};
|
||||
const belowThresholdGroups = {};
|
||||
|
||||
@@ -205,6 +370,15 @@ const updateCampaignItemsStatus = async (ids, status, errorMessage = null) => {
|
||||
`, [status, errorMessage, ids]);
|
||||
};
|
||||
|
||||
const skipIneligibleCampaignItems = async (rows) => {
|
||||
const ineligibleRows = rows.filter(row => !isCampaignEligibleProductName(row.base_product_name || row.nome));
|
||||
const ids = ineligibleRows.map(row => row.id);
|
||||
|
||||
await updateCampaignItemsStatus(ids, 'skipped', 'Produto não elegível para campanha de cliente.');
|
||||
|
||||
return new Set(ineligibleRows.map(row => row.base_product_name)).size;
|
||||
};
|
||||
|
||||
const sendWhatsappCampaign = async (products, customers) => {
|
||||
const response = await fetch(N8N_WHATSAPP_TRIGGER_URL, {
|
||||
method: 'POST',
|
||||
@@ -231,14 +405,21 @@ const processPendingStockCampaigns = async () => {
|
||||
return summary;
|
||||
}
|
||||
|
||||
const groups = groupCampaignRowsByBaseProduct(rows);
|
||||
summary.skippedGroups += await skipIneligibleCampaignItems(rows);
|
||||
|
||||
const eligibleRows = rows.filter(row => isCampaignEligibleProductName(row.base_product_name || row.nome));
|
||||
const groups = groupCampaignRowsByBaseProduct(eligibleRows);
|
||||
const products = mapCampaignProducts(groups);
|
||||
const ids = products.flatMap(product => product.itemIds);
|
||||
const customers = await getTopBuyersAllTime();
|
||||
|
||||
if (!products.length) {
|
||||
return summary;
|
||||
}
|
||||
|
||||
if (!customers.length) {
|
||||
await updateCampaignItemsStatus(ids, 'skipped', 'No customers with valid phone numbers found.');
|
||||
summary.skippedGroups = products.length;
|
||||
summary.skippedGroups += products.length;
|
||||
return summary;
|
||||
}
|
||||
|
||||
@@ -260,6 +441,7 @@ module.exports = {
|
||||
enqueueStockCampaignItem,
|
||||
getCampaignPreview,
|
||||
getCampaignQueueSummary,
|
||||
getTopClientsForCampaign,
|
||||
retryCampaignItems,
|
||||
processPendingStockCampaigns
|
||||
};
|
||||
|
||||
@@ -73,6 +73,10 @@ const mapConsumptionReference = (row) => ({
|
||||
efficiencyPercent: row.efficiency_percent === null ? null : Number(row.efficiency_percent),
|
||||
ribGPerPiece: row.rib_g_per_piece === null ? null : Number(row.rib_g_per_piece),
|
||||
materialCostPerKg: row.material_cost_per_kg === null ? null : Number(row.material_cost_per_kg),
|
||||
consumptionQuantity: row.consumption_quantity === null ? null : Number(row.consumption_quantity),
|
||||
consumptionUnit: row.consumption_unit || '',
|
||||
source: row.source || 'manual',
|
||||
lastProductionOrderId: row.last_production_order_id === null ? null : Number(row.last_production_order_id),
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at
|
||||
});
|
||||
@@ -185,7 +189,8 @@ const listConsumptionReferences = async () => {
|
||||
r.material_product_id, m.sku AS material_sku, m.name AS material_name,
|
||||
r.color, r.general_yield, r.size_yields, r.size_areas,
|
||||
r.gramature, r.efficiency_percent, r.rib_g_per_piece,
|
||||
r.material_cost_per_kg, r.created_at, r.updated_at
|
||||
r.material_cost_per_kg, r.consumption_quantity, r.consumption_unit,
|
||||
r.source, r.last_production_order_id, r.created_at, r.updated_at
|
||||
FROM consumption_references r
|
||||
JOIN catalog_products p ON p.id = r.product_id
|
||||
LEFT JOIN catalog_products m ON m.id = r.material_product_id
|
||||
@@ -208,9 +213,10 @@ const createConsumptionReference = async (payload) => {
|
||||
const calculatedYield = Object.values(sizeYields).length
|
||||
? Object.values(sizeYields).reduce((total, value) => total + value, 0) / Object.values(sizeYields).length
|
||||
: null;
|
||||
const consumptionQuantity = normalizeNumber(payload.consumptionQuantity);
|
||||
|
||||
if (!generalYield && !calculatedYield) {
|
||||
const error = new Error('Informe o rendimento geral ou por tamanho.');
|
||||
if (!generalYield && !calculatedYield && !consumptionQuantity) {
|
||||
const error = new Error('Informe o rendimento ou consumo por peça.');
|
||||
error.statusCode = 400;
|
||||
throw error;
|
||||
}
|
||||
@@ -219,9 +225,9 @@ const createConsumptionReference = async (payload) => {
|
||||
INSERT INTO consumption_references (
|
||||
product_id, material_product_id, color, general_yield, size_yields,
|
||||
size_areas, gramature, efficiency_percent, rib_g_per_piece,
|
||||
material_cost_per_kg, updated_at
|
||||
material_cost_per_kg, consumption_quantity, consumption_unit, source, updated_at
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5::jsonb, $6::jsonb, $7, $8, $9, $10, CURRENT_TIMESTAMP)
|
||||
VALUES ($1, $2, $3, $4, $5::jsonb, $6::jsonb, $7, $8, $9, $10, $11, $12, 'manual', CURRENT_TIMESTAMP)
|
||||
RETURNING id
|
||||
`, [
|
||||
productId,
|
||||
@@ -233,7 +239,9 @@ const createConsumptionReference = async (payload) => {
|
||||
normalizeNumber(payload.gramature),
|
||||
normalizeNumber(payload.efficiencyPercent),
|
||||
normalizeNumber(payload.ribGPerPiece),
|
||||
normalizeNumber(payload.materialCostPerKg)
|
||||
normalizeNumber(payload.materialCostPerKg),
|
||||
consumptionQuantity,
|
||||
normalizeText(payload.consumptionUnit) || null
|
||||
]);
|
||||
|
||||
const references = await listConsumptionReferences();
|
||||
|
||||
@@ -2,6 +2,21 @@ const { pool } = require('../db');
|
||||
|
||||
const FAMILY_KEYS = ['BLCS', 'BLOS', 'BLMC', 'BLPM'];
|
||||
const FAMILY_KEY_SET = new Set(FAMILY_KEYS);
|
||||
const PRODUCT_TYPE_KEYS = [
|
||||
'finished_apparel',
|
||||
'finished_accessory',
|
||||
'raw_material',
|
||||
'packaging',
|
||||
'dtf_input',
|
||||
'dtf_service',
|
||||
'kit_bundle',
|
||||
'service',
|
||||
'machine_part',
|
||||
'equipment',
|
||||
'ignore_from_planning',
|
||||
'unknown'
|
||||
];
|
||||
const PRODUCT_TYPE_KEY_SET = new Set(PRODUCT_TYPE_KEYS);
|
||||
|
||||
const normalizeFamilyKey = (value) => {
|
||||
const familyKey = String(value || '').trim().toUpperCase();
|
||||
@@ -15,6 +30,11 @@ const normalizeNumber = (value) => {
|
||||
|
||||
const normalizeText = (value) => String(value || '').replace(/\s+/g, ' ').trim();
|
||||
|
||||
const normalizeProductType = (value) => {
|
||||
const productType = normalizeText(value);
|
||||
return PRODUCT_TYPE_KEY_SET.has(productType) ? productType : '';
|
||||
};
|
||||
|
||||
const normalizeFamilyYields = (familyYields = {}) => {
|
||||
return FAMILY_KEYS.reduce((normalized, familyKey) => {
|
||||
const unitsPerRoll = normalizeNumber(familyYields[familyKey]);
|
||||
@@ -31,13 +51,17 @@ const normalizeProductOverrides = (productOverrides = {}) => {
|
||||
const familyKey = normalizeFamilyKey(override.familyKey);
|
||||
const color = normalizeText(override.color);
|
||||
const size = normalizeText(override.size).toUpperCase();
|
||||
const productType = normalizeProductType(override.productType);
|
||||
const planningNotes = normalizeText(override.planningNotes);
|
||||
|
||||
if (!familyKey && !color && !size) return normalized;
|
||||
if (!familyKey && !color && !size && !productType && !planningNotes) return normalized;
|
||||
|
||||
normalized[normalizedProductId] = {
|
||||
familyKey,
|
||||
color,
|
||||
size
|
||||
size,
|
||||
productType,
|
||||
planningNotes
|
||||
};
|
||||
|
||||
return normalized;
|
||||
@@ -53,7 +77,7 @@ const listCuttingSettings = async () => {
|
||||
ORDER BY family_key
|
||||
`),
|
||||
pool.query(`
|
||||
SELECT product_id, family_key, color, size
|
||||
SELECT product_id, family_key, color, size, product_type, planning_notes
|
||||
FROM cutting_product_overrides
|
||||
ORDER BY product_id
|
||||
`)
|
||||
@@ -68,7 +92,9 @@ const listCuttingSettings = async () => {
|
||||
settings[row.product_id] = {
|
||||
familyKey: row.family_key || '',
|
||||
color: row.color || '',
|
||||
size: row.size || ''
|
||||
size: row.size || '',
|
||||
productType: row.product_type || '',
|
||||
planningNotes: row.planning_notes || ''
|
||||
};
|
||||
return settings;
|
||||
}, {})
|
||||
@@ -94,13 +120,15 @@ const saveCuttingSettings = async ({ familyYields = {}, productOverrides = {} })
|
||||
await client.query('DELETE FROM cutting_product_overrides');
|
||||
for (const [productId, override] of Object.entries(normalizedProductOverrides)) {
|
||||
await client.query(`
|
||||
INSERT INTO cutting_product_overrides (product_id, family_key, color, size, updated_at)
|
||||
VALUES ($1, $2, $3, $4, CURRENT_TIMESTAMP)
|
||||
INSERT INTO cutting_product_overrides (product_id, family_key, color, size, product_type, planning_notes, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, CURRENT_TIMESTAMP)
|
||||
`, [
|
||||
productId,
|
||||
override.familyKey || null,
|
||||
override.color || null,
|
||||
override.size || null
|
||||
override.size || null,
|
||||
override.productType || null,
|
||||
override.planningNotes || null
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -123,6 +151,7 @@ module.exports = {
|
||||
listCuttingSettings,
|
||||
normalizeFamilyKey,
|
||||
normalizeFamilyYields,
|
||||
normalizeProductType,
|
||||
normalizeProductOverrides,
|
||||
saveCuttingSettings
|
||||
};
|
||||
|
||||
185
backend/services/databaseDiagnosticService.js
Normal file
185
backend/services/databaseDiagnosticService.js
Normal file
@@ -0,0 +1,185 @@
|
||||
const { pool } = require('../db');
|
||||
|
||||
const SAMPLE_LIMIT = 50;
|
||||
|
||||
const sampleSpecs = {
|
||||
catalog_categories: {
|
||||
columns: ['id', 'name', 'description', 'created_at', 'updated_at'],
|
||||
orderBy: ['updated_at', 'created_at', 'id']
|
||||
},
|
||||
catalog_products: {
|
||||
columns: ['id', 'type', 'sku', 'name', 'category_id', 'composition', 'gramature', 'material_yield', 'width_cm', 'color', 'subcategory', 'sizes', 'created_at', 'updated_at'],
|
||||
orderBy: ['updated_at', 'created_at', 'id']
|
||||
},
|
||||
consumption_references: {
|
||||
columns: ['id', 'product_id', 'material_product_id', 'color', 'general_yield', 'size_yields', 'size_areas', 'gramature', 'efficiency_percent', 'rib_g_per_piece', 'material_cost_per_kg', 'consumption_quantity', 'consumption_unit', 'source', 'last_production_order_id', 'created_at', 'updated_at'],
|
||||
orderBy: ['updated_at', 'created_at', 'id']
|
||||
},
|
||||
cutting_family_rules: {
|
||||
columns: ['family_key', 'units_per_roll', 'updated_at'],
|
||||
orderBy: ['family_key']
|
||||
},
|
||||
cutting_product_overrides: {
|
||||
columns: ['product_id', 'family_key', 'color', 'size', 'product_type', 'planning_notes', 'updated_at'],
|
||||
orderBy: ['updated_at', 'product_id']
|
||||
},
|
||||
orders: {
|
||||
columns: ['id', 'pedido_id', 'data_pedido', 'data_pedido_date', 'valor_pedido', 'produto_id', 'produto_descricao', 'quantidade', 'valor_unitario', 'id_vendedor', 'nome_vendedor', 'marketplace', 'canal_venda', 'numero_ecommerce', 'created_at'],
|
||||
orderBy: ['data_pedido_date', 'created_at', 'id']
|
||||
},
|
||||
production_order_markers: {
|
||||
columns: ['id', 'production_order_id', 'label', 'color', 'created_at'],
|
||||
orderBy: ['created_at', 'id']
|
||||
},
|
||||
production_order_components: {
|
||||
columns: ['id', 'production_order_id', 'component_tiny_id', 'component_sku', 'component_name', 'quantity_per_unit', 'total_quantity', 'unit', 'created_at', 'updated_at'],
|
||||
orderBy: ['updated_at', 'created_at', 'id']
|
||||
},
|
||||
production_order_steps: {
|
||||
columns: ['id', 'production_order_id', 'step_number', 'name', 'start_date', 'end_date', 'status', 'color', 'created_at', 'updated_at'],
|
||||
orderBy: ['updated_at', 'created_at', 'id']
|
||||
},
|
||||
product_compositions: {
|
||||
columns: ['id', 'source', 'external_source_id', 'finished_product_identity', 'finished_product_sku', 'finished_product_description', 'finished_product_unit', 'finished_tiny_product_id', 'last_synced_at', 'created_at', 'updated_at'],
|
||||
orderBy: ['last_synced_at', 'updated_at', 'id']
|
||||
},
|
||||
product_composition_components: {
|
||||
columns: ['id', 'product_composition_id', 'component_identity', 'component_tiny_id', 'component_sku', 'component_name', 'quantity_per_unit', 'unit', 'created_at', 'updated_at'],
|
||||
orderBy: ['updated_at', 'created_at', 'id']
|
||||
},
|
||||
production_orders: {
|
||||
columns: ['id', 'tiny_id', 'number', 'status', 'order_reference', 'issue_date', 'expected_date', 'product_sku', 'product_description', 'quantity', 'unit', 'integration_status', 'notes', 'supplier', 'lot_code', 'roll_quantity', 'fabric_kg', 'rib_kg', 'yield_pieces_per_kg', 'created_at', 'updated_at'],
|
||||
orderBy: ['updated_at', 'created_at', 'id']
|
||||
},
|
||||
stock: {
|
||||
columns: ['produto_id', 'nome', 'saldo', 'delta_estoque', 'updated_at'],
|
||||
orderBy: ['updated_at', 'produto_id']
|
||||
},
|
||||
stock_campaign_queue: {
|
||||
columns: ['id', 'base_product_name', 'produto_id', 'nome', 'saldo', 'delta_estoque', 'status', 'attempts', 'last_error', 'created_at', 'updated_at', 'sent_at'],
|
||||
orderBy: ['updated_at', 'created_at', 'id']
|
||||
},
|
||||
supply_fabric_plans: {
|
||||
columns: ['id', 'material', 'color', 'quantity_kg', 'supplier', 'priority', 'status', 'created_at', 'updated_at'],
|
||||
orderBy: ['updated_at', 'created_at', 'id']
|
||||
},
|
||||
supply_movements: {
|
||||
columns: ['id', 'receipt_id', 'lot_id', 'type', 'category', 'product', 'quantity', 'unit', 'reason', 'created_at'],
|
||||
orderBy: ['created_at', 'id']
|
||||
},
|
||||
supply_receipts: {
|
||||
columns: ['id', 'category', 'product', 'quantity', 'unit', 'supplier', 'invoice', 'notes', 'status', 'created_at', 'updated_at', 'approved_at'],
|
||||
orderBy: ['updated_at', 'created_at', 'id']
|
||||
},
|
||||
supply_stock_lots: {
|
||||
columns: ['id', 'receipt_id', 'category', 'product', 'quantity', 'unit', 'supplier', 'invoice', 'status', 'created_at', 'updated_at'],
|
||||
orderBy: ['updated_at', 'created_at', 'id']
|
||||
}
|
||||
};
|
||||
|
||||
const quoteIdentifier = (identifier) => {
|
||||
if (!/^[a-z_][a-z0-9_]*$/.test(identifier)) {
|
||||
throw new Error(`Unsafe database identifier: ${identifier}`);
|
||||
}
|
||||
|
||||
return `"${identifier}"`;
|
||||
};
|
||||
|
||||
const listPublicTables = async () => {
|
||||
const result = await pool.query(`
|
||||
SELECT table_name
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = 'public'
|
||||
AND table_type = 'BASE TABLE'
|
||||
ORDER BY table_name
|
||||
`);
|
||||
|
||||
return result.rows.map(row => row.table_name);
|
||||
};
|
||||
|
||||
const listPublicColumns = async () => {
|
||||
const result = await pool.query(`
|
||||
SELECT table_name, column_name
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = 'public'
|
||||
ORDER BY table_name, ordinal_position
|
||||
`);
|
||||
|
||||
return result.rows.reduce((columnsByTable, row) => {
|
||||
if (!columnsByTable[row.table_name]) columnsByTable[row.table_name] = [];
|
||||
columnsByTable[row.table_name].push(row.column_name);
|
||||
return columnsByTable;
|
||||
}, {});
|
||||
};
|
||||
|
||||
const countTableRows = async (tableName) => {
|
||||
const result = await pool.query(`SELECT COUNT(*)::int AS count FROM ${quoteIdentifier(tableName)}`);
|
||||
return result.rows[0]?.count || 0;
|
||||
};
|
||||
|
||||
const buildOrderClause = (spec, availableColumns) => {
|
||||
const orderColumns = spec.orderBy.filter(column => availableColumns.includes(column));
|
||||
if (!orderColumns.length) return '';
|
||||
|
||||
const clauses = orderColumns.map(column => {
|
||||
const direction = column === 'family_key' || column === 'product_id' || column === 'produto_id' ? 'ASC' : 'DESC';
|
||||
return `${quoteIdentifier(column)} ${direction}`;
|
||||
});
|
||||
|
||||
return ` ORDER BY ${clauses.join(', ')}`;
|
||||
};
|
||||
|
||||
const sampleTableRows = async (tableName, availableColumns) => {
|
||||
const spec = sampleSpecs[tableName];
|
||||
if (!spec) return null;
|
||||
|
||||
const selectedColumns = spec.columns.filter(column => availableColumns.includes(column));
|
||||
if (!selectedColumns.length) return null;
|
||||
|
||||
const selectClause = selectedColumns.map(quoteIdentifier).join(', ');
|
||||
const orderClause = buildOrderClause(spec, availableColumns);
|
||||
const result = await pool.query(
|
||||
`SELECT ${selectClause} FROM ${quoteIdentifier(tableName)}${orderClause} LIMIT $1`,
|
||||
[SAMPLE_LIMIT]
|
||||
);
|
||||
|
||||
return result.rows;
|
||||
};
|
||||
|
||||
const buildDatabaseDiagnostic = async (user) => {
|
||||
const [tables, columnsByTable] = await Promise.all([
|
||||
listPublicTables(),
|
||||
listPublicColumns()
|
||||
]);
|
||||
|
||||
const counts = {};
|
||||
const samples = {};
|
||||
|
||||
for (const tableName of tables) {
|
||||
counts[tableName] = await countTableRows(tableName);
|
||||
|
||||
const sampleRows = await sampleTableRows(tableName, columnsByTable[tableName] || []);
|
||||
if (sampleRows) samples[tableName] = sampleRows;
|
||||
}
|
||||
|
||||
return {
|
||||
generatedAt: new Date().toISOString(),
|
||||
generatedBy: {
|
||||
role: user?.role || null,
|
||||
userId: user?.userId || null
|
||||
},
|
||||
sampleLimit: SAMPLE_LIMIT,
|
||||
privacy: {
|
||||
countsIncludeAllPublicTables: true,
|
||||
samplesExcludeTables: ['app_users', 'client_identity_tokens'],
|
||||
ordersSampleExcludesCustomerNameAndPhone: true,
|
||||
productionOrdersSampleExcludesTinyPayload: true
|
||||
},
|
||||
counts,
|
||||
samples
|
||||
};
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
buildDatabaseDiagnostic
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
@@ -24,6 +24,13 @@ const normalizeKey = (value) => normalizeText(value)
|
||||
|
||||
const normalizeSku = (value) => normalizeText(value).toUpperCase();
|
||||
|
||||
const normalizeUnit = (value) => {
|
||||
const unit = normalizeText(value).toLowerCase();
|
||||
if (['kg', 'quilo', 'quilos'].includes(unit)) return 'kg';
|
||||
if (['un', 'und', 'un.', 'unidade', 'unidades'].includes(unit)) return 'un.';
|
||||
return unit || 'un.';
|
||||
};
|
||||
|
||||
const mapReceipt = (row) => ({
|
||||
id: row.id,
|
||||
category: row.category,
|
||||
@@ -249,7 +256,10 @@ const listConsumptionReferenceRows = async () => {
|
||||
m.name AS material_name,
|
||||
r.color,
|
||||
r.general_yield,
|
||||
r.size_yields
|
||||
r.size_yields,
|
||||
r.consumption_quantity,
|
||||
r.consumption_unit,
|
||||
r.source
|
||||
FROM consumption_references r
|
||||
JOIN catalog_products p ON p.id = r.product_id
|
||||
LEFT JOIN catalog_products m ON m.id = r.material_product_id
|
||||
@@ -259,6 +269,37 @@ const listConsumptionReferenceRows = async () => {
|
||||
return result.rows;
|
||||
};
|
||||
|
||||
// The Tiny structure is the source of truth when it exists. Consumption
|
||||
// references remain useful as a fallback for legacy/manual products, but they
|
||||
// must not make an imported structure invisible to purchase planning.
|
||||
const listCompositionRows = async () => {
|
||||
const result = await pool.query(`
|
||||
SELECT
|
||||
composition.finished_tiny_product_id,
|
||||
composition.finished_product_sku,
|
||||
component.component_tiny_id,
|
||||
component.component_sku,
|
||||
component.component_name,
|
||||
component.quantity_per_unit,
|
||||
component.unit
|
||||
FROM product_compositions composition
|
||||
JOIN product_composition_components component
|
||||
ON component.product_composition_id = composition.id
|
||||
WHERE composition.source = 'tiny_olist_v3'
|
||||
ORDER BY composition.id, component.id;
|
||||
`);
|
||||
return result.rows;
|
||||
};
|
||||
|
||||
const listTinyStockRows = async () => {
|
||||
const result = await pool.query(`
|
||||
SELECT produto_id, nome, COALESCE(saldo, 0)::numeric AS saldo
|
||||
FROM stock
|
||||
WHERE COALESCE(produto_id, '') <> '' OR COALESCE(nome, '') <> '';
|
||||
`);
|
||||
return result.rows;
|
||||
};
|
||||
|
||||
const mergeNeedLine = (needsByMaterial, key, patch) => {
|
||||
const current = needsByMaterial.get(key) || {
|
||||
material: patch.material,
|
||||
@@ -292,13 +333,84 @@ const mergeNeedLine = (needsByMaterial, key, patch) => {
|
||||
};
|
||||
|
||||
const buildProjectPurchaseNeeds = async (lots, receipts) => {
|
||||
const [demandRows, referenceRows] = await Promise.all([
|
||||
const [demandRows, referenceRows, compositionRows, tinyStockRows] = await Promise.all([
|
||||
listProjectDemandRows(),
|
||||
listConsumptionReferenceRows()
|
||||
listConsumptionReferenceRows(),
|
||||
listCompositionRows(),
|
||||
listTinyStockRows()
|
||||
]);
|
||||
const referencesBySku = new Map(referenceRows.map(reference => [normalizeSku(reference.product_sku), reference]));
|
||||
const referencesBySku = referenceRows.reduce((references, reference) => {
|
||||
const sku = normalizeSku(reference.product_sku);
|
||||
if (!sku) return references;
|
||||
references.set(sku, [...(references.get(sku) || []), reference]);
|
||||
return references;
|
||||
}, new Map());
|
||||
const needsByMaterial = new Map();
|
||||
|
||||
const compositionsByProduct = compositionRows.reduce((compositions, component) => {
|
||||
[component.finished_tiny_product_id, component.finished_product_sku]
|
||||
.map(normalizeSku)
|
||||
.filter(Boolean)
|
||||
.forEach(identity => {
|
||||
compositions.set(identity, [...(compositions.get(identity) || []), component]);
|
||||
});
|
||||
return compositions;
|
||||
}, new Map());
|
||||
|
||||
// A need is keyed by the imported Tiny component identity whenever
|
||||
// possible. Names are retained for display and for matching manual lots.
|
||||
const componentNeedKeys = new Map();
|
||||
const registerNeedKey = (identity, key) => {
|
||||
const normalizedIdentity = normalizeSku(identity);
|
||||
if (normalizedIdentity) componentNeedKeys.set(normalizedIdentity, key);
|
||||
};
|
||||
const addCompositionNeed = (component, row, productId, suggestedQuantity, quantitySold, stockQuantity) => {
|
||||
const unit = normalizeUnit(component.unit);
|
||||
const material = normalizeText(component.component_name)
|
||||
|| normalizeText(component.component_sku)
|
||||
|| normalizeText(component.component_tiny_id)
|
||||
|| 'Material sem cadastro';
|
||||
const quantityPerUnit = Number(component.quantity_per_unit || 0);
|
||||
const tinyId = normalizeText(component.component_tiny_id);
|
||||
const sku = normalizeSku(component.component_sku);
|
||||
const identity = tinyId || sku || normalizeKey(material);
|
||||
const key = `${unit}:${normalizeSku(identity) || normalizeKey(material)}`;
|
||||
|
||||
if (!Number.isFinite(quantityPerUnit) || quantityPerUnit <= 0) {
|
||||
mergeNeedLine(needsByMaterial, `missing-quantity:${productId}:${identity}`, {
|
||||
material: `Revisar quantidade: ${normalizeText(row.product_name) || productId}`,
|
||||
plannedKg: suggestedQuantity,
|
||||
priority: 'Crítico',
|
||||
unit: 'un.',
|
||||
source: 'tiny_composition',
|
||||
missingReference: true,
|
||||
product: { productId, name: normalizeText(row.product_name), suggestedQuantity, quantitySold, stockQuantity }
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
mergeNeedLine(needsByMaterial, key, {
|
||||
material,
|
||||
plannedKg: suggestedQuantity * quantityPerUnit,
|
||||
priority: 'Atenção',
|
||||
unit,
|
||||
source: 'tiny_composition',
|
||||
product: {
|
||||
productId,
|
||||
name: normalizeText(row.product_name),
|
||||
suggestedQuantity,
|
||||
quantitySold,
|
||||
stockQuantity,
|
||||
consumptionQuantity: quantityPerUnit,
|
||||
consumptionUnit: unit
|
||||
}
|
||||
});
|
||||
registerNeedKey(tinyId, key);
|
||||
registerNeedKey(sku, key);
|
||||
// Lots are normally registered by material name, not Tiny ID.
|
||||
registerNeedKey(material, key);
|
||||
};
|
||||
|
||||
demandRows.forEach(row => {
|
||||
const productId = normalizeSku(row.product_id);
|
||||
if (!productId) return;
|
||||
@@ -309,8 +421,14 @@ const buildProjectPurchaseNeeds = async (lots, receipts) => {
|
||||
const suggestedQuantity = Math.max(Math.ceil(projectedDemand - stockQuantity), 0);
|
||||
if (suggestedQuantity <= 0) return;
|
||||
|
||||
const reference = referencesBySku.get(productId);
|
||||
if (!reference) {
|
||||
const composition = compositionsByProduct.get(productId) || [];
|
||||
if (composition.length) {
|
||||
composition.forEach(component => addCompositionNeed(component, row, productId, suggestedQuantity, quantitySold, stockQuantity));
|
||||
return;
|
||||
}
|
||||
|
||||
const references = referencesBySku.get(productId) || [];
|
||||
if (!references.length) {
|
||||
mergeNeedLine(needsByMaterial, `missing:${productId}`, {
|
||||
material: `Cadastrar consumo: ${normalizeText(row.product_name) || productId}`,
|
||||
plannedKg: suggestedQuantity,
|
||||
@@ -329,9 +447,35 @@ const buildProjectPurchaseNeeds = async (lots, receipts) => {
|
||||
return;
|
||||
}
|
||||
|
||||
references.forEach(reference => {
|
||||
const consumptionQuantity = Number(reference.consumption_quantity || 0);
|
||||
const consumptionUnit = normalizeUnit(reference.consumption_unit);
|
||||
const materialName = normalizeText(reference.material_name) || normalizeText(reference.material_sku) || 'Material sem cadastro';
|
||||
|
||||
if (consumptionQuantity > 0) {
|
||||
mergeNeedLine(needsByMaterial, `${consumptionUnit}:${normalizeKey(materialName)}`, {
|
||||
material: materialName,
|
||||
plannedKg: suggestedQuantity * consumptionQuantity,
|
||||
priority: 'Atenção',
|
||||
unit: consumptionUnit,
|
||||
source: reference.source || 'project_demand',
|
||||
color: reference.color,
|
||||
product: {
|
||||
productId,
|
||||
name: normalizeText(row.product_name),
|
||||
suggestedQuantity,
|
||||
quantitySold,
|
||||
stockQuantity,
|
||||
consumptionQuantity,
|
||||
consumptionUnit
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const yieldPerKg = getReferenceYield(reference);
|
||||
if (!yieldPerKg) {
|
||||
mergeNeedLine(needsByMaterial, `missing-yield:${productId}`, {
|
||||
mergeNeedLine(needsByMaterial, `missing-yield:${productId}:${reference.material_product_id || 'material'}`, {
|
||||
material: `Cadastrar rendimento: ${normalizeText(row.product_name) || productId}`,
|
||||
plannedKg: suggestedQuantity,
|
||||
priority: 'Crítico',
|
||||
@@ -350,13 +494,12 @@ const buildProjectPurchaseNeeds = async (lots, receipts) => {
|
||||
return;
|
||||
}
|
||||
|
||||
const materialName = normalizeText(reference.material_name) || normalizeText(reference.material_sku) || 'Material sem cadastro';
|
||||
mergeNeedLine(needsByMaterial, normalizeKey(materialName), {
|
||||
mergeNeedLine(needsByMaterial, `kg:${normalizeKey(materialName)}`, {
|
||||
material: materialName,
|
||||
plannedKg: suggestedQuantity / yieldPerKg,
|
||||
priority: 'Atenção',
|
||||
unit: 'kg',
|
||||
source: 'project_demand',
|
||||
source: reference.source || 'project_demand',
|
||||
color: reference.color,
|
||||
product: {
|
||||
productId,
|
||||
@@ -368,17 +511,35 @@ const buildProjectPurchaseNeeds = async (lots, receipts) => {
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
const getNeedForInventory = (unit, id, name) => {
|
||||
const normalizedUnit = normalizeUnit(unit);
|
||||
const key = componentNeedKeys.get(normalizeSku(id)) || componentNeedKeys.get(normalizeSku(name));
|
||||
return key && key.startsWith(`${normalizedUnit}:`) ? needsByMaterial.get(key) : undefined;
|
||||
};
|
||||
|
||||
// Tiny saldo is material stock too. It is matched using the component ID
|
||||
// first, then SKU/name, which lets "MALHA PRETA" stock directly reduce the
|
||||
// material purchase suggestion imported from the product structure.
|
||||
tinyStockRows.forEach(stock => {
|
||||
const need = getNeedForInventory('un.', stock.produto_id, stock.nome)
|
||||
|| getNeedForInventory('kg', stock.produto_id, stock.nome)
|
||||
|| getNeedForInventory('', stock.produto_id, stock.nome);
|
||||
if (need) need.stockKg += Number(stock.saldo || 0);
|
||||
});
|
||||
|
||||
lots.forEach(lot => {
|
||||
if (lot.unit !== 'kg') return;
|
||||
const need = needsByMaterial.get(normalizeKey(lot.product));
|
||||
if (need && need.unit === 'kg') need.stockKg += lot.quantity;
|
||||
const need = getNeedForInventory(lot.unit, '', lot.product)
|
||||
|| needsByMaterial.get(`${normalizeUnit(lot.unit)}:${normalizeKey(lot.product)}`);
|
||||
if (need) need.stockKg += lot.quantity;
|
||||
});
|
||||
|
||||
receipts.forEach(receipt => {
|
||||
if (receipt.status !== 'pending' || receipt.unit !== 'kg') return;
|
||||
const need = needsByMaterial.get(normalizeKey(receipt.product));
|
||||
if (need && need.unit === 'kg') need.pendingKg += receipt.quantity;
|
||||
if (receipt.status !== 'pending') return;
|
||||
const need = getNeedForInventory(receipt.unit, '', receipt.product)
|
||||
|| needsByMaterial.get(`${normalizeUnit(receipt.unit)}:${normalizeKey(receipt.product)}`);
|
||||
if (need) need.pendingKg += receipt.quantity;
|
||||
});
|
||||
|
||||
return Array.from(needsByMaterial.values()).map(need => {
|
||||
@@ -422,9 +583,11 @@ const mergePurchaseNeeds = (manualNeeds, projectNeeds) => {
|
||||
}
|
||||
|
||||
current.plannedKg += need.plannedKg;
|
||||
current.stockKg += need.stockKg;
|
||||
current.pendingKg += need.pendingKg;
|
||||
current.purchaseKg += need.purchaseKg;
|
||||
// Stock/receipts are the same physical inventory for a manual plan and
|
||||
// an imported-composition need. They are alternatives views of the
|
||||
// balance, never amounts to add together.
|
||||
current.stockKg = Math.max(current.stockKg, need.stockKg);
|
||||
current.pendingKg = Math.max(current.pendingKg, need.pendingKg);
|
||||
current.priority = need.priority === 'Crítico' || current.priority === 'Crítico'
|
||||
? 'Crítico'
|
||||
: need.priority === 'Atenção' || current.priority === 'Atenção'
|
||||
@@ -443,11 +606,18 @@ const mergePurchaseNeeds = (manualNeeds, projectNeeds) => {
|
||||
});
|
||||
|
||||
return Array.from(mergedByKey.values())
|
||||
.map(need => ({
|
||||
.map(need => {
|
||||
const purchaseKg = Math.max(need.plannedKg - need.stockKg - need.pendingKg, 0);
|
||||
return {
|
||||
...need,
|
||||
purchaseKg,
|
||||
status: purchaseKg > 0
|
||||
? (need.priority === 'Crítico' || need.stockKg === 0 ? 'critical' : 'attention')
|
||||
: 'ok',
|
||||
suppliers: Array.from(need.suppliers),
|
||||
colors: Array.from(need.colors)
|
||||
}))
|
||||
};
|
||||
})
|
||||
.sort((a, b) => {
|
||||
const statusOrder = { critical: 1, attention: 2, ok: 3 };
|
||||
return statusOrder[a.status] - statusOrder[b.status] || b.purchaseKg - a.purchaseKg || a.material.localeCompare(b.material);
|
||||
|
||||
@@ -6,6 +6,7 @@ const {
|
||||
formatProductNameForDisplay,
|
||||
groupCampaignRows,
|
||||
groupCampaignRowsByBaseProduct,
|
||||
isCampaignEligibleProductName,
|
||||
mapCampaignProducts
|
||||
} = require('../services/campaignFormatter');
|
||||
|
||||
@@ -58,6 +59,39 @@ test('formatProductNameForDisplay applies customer-facing campaign aliases', ()
|
||||
);
|
||||
});
|
||||
|
||||
test('isCampaignEligibleProductName allows only customer-facing apparel campaigns', () => {
|
||||
[
|
||||
'Camiseta Premium Cor Bordo',
|
||||
'Camiseta Premium Cor Preto',
|
||||
'Moletom Canguru Premium Cor Preto',
|
||||
'IMPRESSÃO DTF PERSONALIZADO 57X100 (1 METRO)',
|
||||
'BASE LISA CAMISETA COR PRETO TAMANHO - G',
|
||||
'BASE LISA MOLETOM CANGURU COR PRETO'
|
||||
].forEach(name => {
|
||||
assert.equal(isCampaignEligibleProductName(name), true, name);
|
||||
});
|
||||
|
||||
[
|
||||
'Ilhos Com Arruela',
|
||||
'Atacador 001 Chato Preto 1,20 Mt',
|
||||
'2099 Ribana 2x1 Cor Bordo',
|
||||
'2001.09 Ribana 2x1 Cor Cinza',
|
||||
'2001.09 Malha Camiseta 30oe Cor Cinza',
|
||||
'201 Malha Camiseta 30oe Cor Preto',
|
||||
'4006 Malha Camiseta 30oe Cor Marinho',
|
||||
'TINTA DTF 1 LITRO - BRANCO',
|
||||
'FILME DTF ROLO 60CM',
|
||||
'POLIAMIDA EM PÓ PARA DTF - 1 KG',
|
||||
'SALDO ESTOQUE CAMISETA'
|
||||
].forEach(name => {
|
||||
assert.equal(isCampaignEligibleProductName(name), false, name);
|
||||
});
|
||||
});
|
||||
|
||||
test('isCampaignEligibleProductName keeps accessories out of WhatsApp apparel campaigns', () => {
|
||||
assert.equal(isCampaignEligibleProductName('BONÉ - PRETO'), false);
|
||||
});
|
||||
|
||||
test('mapCampaignProducts accumulates split deltas by base product', () => {
|
||||
const groups = groupCampaignRowsByBaseProduct([
|
||||
row({ id: 1, delta_estoque: 10, produto_id: 'SKU-P', nome: 'Produto Split TAMANHO - P' }),
|
||||
|
||||
111
backend/test/campaignService.test.js
Normal file
111
backend/test/campaignService.test.js
Normal 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/);
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,7 @@ const test = require('node:test');
|
||||
const {
|
||||
normalizeFamilyKey,
|
||||
normalizeFamilyYields,
|
||||
normalizeProductType,
|
||||
normalizeProductOverrides
|
||||
} = require('../services/cuttingSettingsService');
|
||||
|
||||
@@ -27,14 +28,23 @@ test('normalizeFamilyYields keeps positive numeric yield rules', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('normalizeProductType accepts only known planning product types', () => {
|
||||
assert.equal(normalizeProductType('finished_apparel'), 'finished_apparel');
|
||||
assert.equal(normalizeProductType('raw_material'), 'raw_material');
|
||||
assert.equal(normalizeProductType('finished_product'), '');
|
||||
assert.equal(normalizeProductType('unknown type'), '');
|
||||
});
|
||||
|
||||
test('normalizeProductOverrides trims and removes empty overrides', () => {
|
||||
assert.deepEqual(normalizeProductOverrides({
|
||||
' SKU-1 ': { familyKey: 'blcs', color: ' Preto ', size: ' m ' },
|
||||
' SKU-1 ': { familyKey: 'blcs', color: ' Preto ', size: ' m ', productType: 'finished_apparel', planningNotes: ' revisar corte ' },
|
||||
'SKU-2': { familyKey: 'OUTROS', color: '', size: '' },
|
||||
'SKU-3': { familyKey: '', color: ' Branco ', size: '' },
|
||||
'SKU-4': null
|
||||
'SKU-4': { productType: 'raw_material' },
|
||||
'SKU-5': null
|
||||
}), {
|
||||
'SKU-1': { familyKey: 'BLCS', color: 'Preto', size: 'M' },
|
||||
'SKU-3': { familyKey: '', color: 'Branco', size: '' }
|
||||
'SKU-1': { familyKey: 'BLCS', color: 'Preto', size: 'M', productType: 'finished_apparel', planningNotes: 'revisar corte' },
|
||||
'SKU-3': { familyKey: '', color: 'Branco', size: '', productType: '', planningNotes: '' },
|
||||
'SKU-4': { familyKey: '', color: '', size: '', productType: 'raw_material', planningNotes: '' }
|
||||
});
|
||||
});
|
||||
|
||||
374
backend/test/productCompositionService.test.js
Normal file
374
backend/test/productCompositionService.test.js
Normal file
@@ -0,0 +1,374 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const test = require('node:test');
|
||||
|
||||
const { pool } = require('../db');
|
||||
const productionOrderRouter = require('../routes/productionOrderRoutes');
|
||||
const analyticsRouter = require('../routes/analyticsRoutes');
|
||||
|
||||
const getRouteHandler = (router, method, path) => {
|
||||
const route = router.stack.find(layer => layer.route?.path === path && layer.route.methods[method]);
|
||||
if (!route) throw new Error(`Route not found: ${method.toUpperCase()} ${path}`);
|
||||
return route.route.stack.at(-1).handle;
|
||||
};
|
||||
|
||||
const tinySyncHandler = getRouteHandler(productionOrderRouter, 'post', '/production-orders/tiny-sync');
|
||||
const tinyCompositionImportHandler = getRouteHandler(productionOrderRouter, 'post', '/production-orders/tiny-compositions/import');
|
||||
const productCompositionHandler = getRouteHandler(analyticsRouter, 'get', '/analytics/products/:productId/composition');
|
||||
const productCompositionsHandler = getRouteHandler(analyticsRouter, 'get', '/analytics/product-compositions');
|
||||
|
||||
const invokeHandler = async (handler, req) => {
|
||||
let statusCode = 200;
|
||||
let body;
|
||||
const res = {
|
||||
status(code) {
|
||||
statusCode = code;
|
||||
return this;
|
||||
},
|
||||
json(payload) {
|
||||
body = payload;
|
||||
return this;
|
||||
}
|
||||
};
|
||||
await handler(req, res);
|
||||
return { statusCode, body };
|
||||
};
|
||||
|
||||
const compositionPayload = (components = [
|
||||
{
|
||||
componentTinyId: '919498232',
|
||||
componentName: 'ETIQUETA DE TAMANHO GG',
|
||||
componentSku: 'ETIQ.T.GG',
|
||||
quantityPerUnit: '1',
|
||||
totalQuantity: '1',
|
||||
unit: 'UN'
|
||||
},
|
||||
{
|
||||
componentTinyId: '976059144',
|
||||
componentName: '6118 MALHA CAMISETA 30OE COR CAFE',
|
||||
componentSku: 'MC.30.CAF',
|
||||
quantityPerUnit: '0.1851',
|
||||
totalQuantity: '0.1851',
|
||||
unit: 'KG'
|
||||
}
|
||||
]) => ({
|
||||
order: {
|
||||
tinyId: 'STRUCTURE-V3-976058813',
|
||||
number: 'STRUCTURE-V3-BLCS.CAF.GG',
|
||||
status: 'completed',
|
||||
productSku: 'BLCS.CAF.GG',
|
||||
productDescription: 'BASE LISA CAMISETA COR CAFE TAMANHO - GG',
|
||||
quantity: '1',
|
||||
unit: 'UN',
|
||||
notes: 'Composição sincronizada da API pública Olist V3.'
|
||||
},
|
||||
components,
|
||||
steps: []
|
||||
});
|
||||
|
||||
const withFakeDatabase = async (run) => {
|
||||
const originalConnect = pool.connect;
|
||||
const originalQuery = pool.query;
|
||||
const state = {
|
||||
nextCompositionId: 1,
|
||||
nextOrderId: 1,
|
||||
nextCategoryId: 1,
|
||||
nextCatalogProductId: 1,
|
||||
nextConsumptionReferenceId: 1,
|
||||
compositions: [],
|
||||
compositionComponents: [],
|
||||
productionOrders: [],
|
||||
categories: [],
|
||||
catalogProducts: [],
|
||||
consumptionReferences: []
|
||||
};
|
||||
|
||||
const query = async (sql, params = []) => {
|
||||
const normalizedSql = String(sql).replace(/\s+/g, ' ').trim();
|
||||
|
||||
if (/^(BEGIN|COMMIT|ROLLBACK);?$/.test(normalizedSql)) return { rows: [] };
|
||||
|
||||
if (normalizedSql.startsWith('INSERT INTO catalog_categories')) {
|
||||
let category = state.categories.find(item => item.name === params[0]);
|
||||
if (!category) {
|
||||
category = { id: state.nextCategoryId++, name: params[0], description: params[1] };
|
||||
state.categories.push(category);
|
||||
}
|
||||
return { rows: [{ id: category.id }] };
|
||||
}
|
||||
|
||||
if (normalizedSql.startsWith('INSERT INTO catalog_products')) {
|
||||
let product = state.catalogProducts.find(item => item.sku === params[1]);
|
||||
if (!product) {
|
||||
product = {
|
||||
id: state.nextCatalogProductId++,
|
||||
type: params[0],
|
||||
sku: params[1],
|
||||
name: params[2],
|
||||
category_id: params[3],
|
||||
notes: params[4]
|
||||
};
|
||||
state.catalogProducts.push(product);
|
||||
} else {
|
||||
product.type = params[0];
|
||||
product.name = params[2];
|
||||
product.category_id = product.category_id || params[3];
|
||||
product.notes = product.notes || params[4];
|
||||
}
|
||||
return { rows: [{ id: product.id }] };
|
||||
}
|
||||
|
||||
if (normalizedSql.startsWith('SELECT id FROM consumption_references')) {
|
||||
const reference = state.consumptionReferences.find(item => (
|
||||
item.product_id === params[0]
|
||||
&& (item.material_product_id || 0) === params[1]
|
||||
&& (item.color || '') === ''
|
||||
&& (item.source || 'manual') === params[2]
|
||||
));
|
||||
return { rows: reference ? [{ id: reference.id }] : [] };
|
||||
}
|
||||
|
||||
if (normalizedSql.startsWith('UPDATE consumption_references')) {
|
||||
const reference = state.consumptionReferences.find(item => item.id === params[5]);
|
||||
if (reference) {
|
||||
reference.general_yield = params[0];
|
||||
reference.consumption_quantity = params[1];
|
||||
reference.consumption_unit = params[2];
|
||||
reference.last_production_order_id = params[3];
|
||||
reference.source = params[4];
|
||||
}
|
||||
return { rows: [] };
|
||||
}
|
||||
|
||||
if (normalizedSql.startsWith('INSERT INTO consumption_references')) {
|
||||
state.consumptionReferences.push({
|
||||
id: state.nextConsumptionReferenceId++,
|
||||
product_id: params[0],
|
||||
material_product_id: params[1],
|
||||
general_yield: params[2],
|
||||
consumption_quantity: params[3],
|
||||
consumption_unit: params[4],
|
||||
source: params[5],
|
||||
last_production_order_id: params[6]
|
||||
});
|
||||
return { rows: [] };
|
||||
}
|
||||
|
||||
if (normalizedSql.startsWith('SELECT id FROM product_compositions WHERE source')) {
|
||||
const composition = state.compositions.find(item => item.source === params[0] && item.finished_product_identity === params[1]);
|
||||
return { rows: composition ? [{ id: composition.id }] : [] };
|
||||
}
|
||||
|
||||
if (normalizedSql.startsWith('INSERT INTO product_compositions')) {
|
||||
const composition = {
|
||||
id: state.nextCompositionId++,
|
||||
source: params[0],
|
||||
external_source_id: params[1],
|
||||
finished_product_identity: params[2],
|
||||
finished_product_sku: params[3],
|
||||
finished_product_description: params[4],
|
||||
finished_product_unit: params[5],
|
||||
finished_tiny_product_id: params[6],
|
||||
source_metadata: JSON.parse(params[7]),
|
||||
last_synced_at: '2026-07-29T12:00:00.000Z'
|
||||
};
|
||||
state.compositions.push(composition);
|
||||
return { rows: [{ id: composition.id }] };
|
||||
}
|
||||
|
||||
if (normalizedSql.startsWith('UPDATE product_compositions')) {
|
||||
const composition = state.compositions.find(item => item.id === params[7]);
|
||||
composition.external_source_id = params[0];
|
||||
composition.finished_product_sku = params[2];
|
||||
composition.finished_product_description = params[3];
|
||||
composition.finished_product_unit = params[4];
|
||||
composition.finished_tiny_product_id = params[5];
|
||||
composition.source_metadata = JSON.parse(params[6]);
|
||||
return { rows: [] };
|
||||
}
|
||||
|
||||
if (normalizedSql.startsWith('DELETE FROM product_composition_components')) {
|
||||
state.compositionComponents = state.compositionComponents.filter(item => item.product_composition_id !== params[0]);
|
||||
return { rows: [] };
|
||||
}
|
||||
|
||||
if (normalizedSql.startsWith('INSERT INTO product_composition_components')) {
|
||||
state.compositionComponents.push({
|
||||
id: state.compositionComponents.length + 1,
|
||||
product_composition_id: params[0],
|
||||
component_identity: params[1],
|
||||
component_tiny_id: params[2],
|
||||
component_sku: params[3],
|
||||
component_name: params[4],
|
||||
quantity_per_unit: params[5],
|
||||
unit: params[6]
|
||||
});
|
||||
return { rows: [] };
|
||||
}
|
||||
|
||||
if (normalizedSql.includes('FROM product_compositions composition')) {
|
||||
const compositions = state.compositions.filter(item => (
|
||||
item.source === params[0]
|
||||
&& (params[1] === '' || item.finished_tiny_product_id === params[1] || item.finished_product_sku === String(params[1]).toUpperCase())
|
||||
));
|
||||
return {
|
||||
rows: compositions.map(composition => ({
|
||||
...composition,
|
||||
components: state.compositionComponents
|
||||
.filter(item => item.product_composition_id === composition.id)
|
||||
.map(item => ({ ...item, component_product_id: null }))
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
if (normalizedSql.startsWith('SELECT id FROM production_orders')) {
|
||||
return { rows: [] };
|
||||
}
|
||||
|
||||
if (normalizedSql.startsWith('INSERT INTO production_orders')) {
|
||||
const order = {
|
||||
id: state.nextOrderId++,
|
||||
tiny_id: params[0], number: params[1], status: params[2], order_reference: params[3],
|
||||
issue_date: params[4], expected_date: params[5], product_sku: params[6],
|
||||
product_description: params[7], quantity: params[8], unit: params[9],
|
||||
integration_status: params[10], notes: params[11], supplier: params[12], lot_code: params[13],
|
||||
roll_quantity: params[14], fabric_kg: params[15], rib_kg: params[16],
|
||||
yield_pieces_per_kg: params[17], created_at: null, updated_at: null
|
||||
};
|
||||
state.productionOrders.push(order);
|
||||
return { rows: [{ id: order.id }] };
|
||||
}
|
||||
|
||||
if (normalizedSql.startsWith('DELETE FROM production_order_components') || normalizedSql.startsWith('DELETE FROM production_order_steps')) {
|
||||
return { rows: [] };
|
||||
}
|
||||
|
||||
if (normalizedSql.includes('FROM production_orders po') && normalizedSql.includes('WHERE po.id = $1')) {
|
||||
const order = state.productionOrders.find(item => item.id === params[0]);
|
||||
return { rows: order ? [{ ...order, markers: [], components: [], steps: [] }] : [] };
|
||||
}
|
||||
|
||||
throw new Error(`Unexpected SQL in test double: ${normalizedSql.slice(0, 100)}`);
|
||||
};
|
||||
|
||||
pool.query = query;
|
||||
pool.connect = async () => ({ query, release() {} });
|
||||
try {
|
||||
await run(state);
|
||||
} finally {
|
||||
pool.connect = originalConnect;
|
||||
pool.query = originalQuery;
|
||||
}
|
||||
};
|
||||
|
||||
test('Tiny/Olist V3 first import creates a composition without creating an OP', async () => {
|
||||
await withFakeDatabase(async (state) => {
|
||||
const response = await invokeHandler(tinySyncHandler, { body: compositionPayload() });
|
||||
const result = response.body;
|
||||
|
||||
assert.equal(response.statusCode, 201);
|
||||
assert.equal(result.componentCount, 2);
|
||||
assert.equal(state.compositions.length, 1);
|
||||
assert.equal(state.compositionComponents.length, 2);
|
||||
assert.equal(state.productionOrders.length, 0);
|
||||
assert.equal(state.consumptionReferences.length, 2);
|
||||
assert.equal(result.composition.finishedTinyProductId, '976058813');
|
||||
});
|
||||
});
|
||||
|
||||
test('Tiny/Olist V3 bulk import stores compositions and consumption references', async () => {
|
||||
await withFakeDatabase(async (state) => {
|
||||
const response = await invokeHandler(tinyCompositionImportHandler, {
|
||||
body: {
|
||||
exportedAt: '2026-07-31T13:37:01.340Z',
|
||||
compositions: [{
|
||||
finishedProductSku: 'BLCS.CAF.GG',
|
||||
finishedProductDescription: 'BASE LISA CAMISETA COR CAFE TAMANHO - GG',
|
||||
finishedProductUnit: 'UN',
|
||||
finishedTinyProductId: '976058813',
|
||||
sourceMetadata: { payload: compositionPayload() },
|
||||
components: []
|
||||
}]
|
||||
}
|
||||
});
|
||||
|
||||
assert.equal(response.statusCode, 201);
|
||||
assert.equal(response.body.imported, 1);
|
||||
assert.equal(response.body.referenceCount, 2);
|
||||
assert.equal(state.compositions.length, 1);
|
||||
assert.equal(state.consumptionReferences.length, 2);
|
||||
assert.equal(state.consumptionReferences[0].source, 'tiny_structure');
|
||||
});
|
||||
});
|
||||
|
||||
test('Tiny/Olist V3 re-sync replaces composition components without duplicates', async () => {
|
||||
await withFakeDatabase(async (state) => {
|
||||
await invokeHandler(tinySyncHandler, { body: compositionPayload() });
|
||||
await invokeHandler(tinySyncHandler, { body: compositionPayload([
|
||||
{
|
||||
componentTinyId: '976059144', componentName: 'MALHA ATUALIZADA', componentSku: 'MC.30.CAF',
|
||||
quantityPerUnit: '0.2', totalQuantity: '0.2', unit: 'KG'
|
||||
},
|
||||
{
|
||||
componentTinyId: '976059144', componentName: 'MALHA ATUALIZADA', componentSku: 'MC.30.CAF',
|
||||
quantityPerUnit: '0.2', totalQuantity: '0.2', unit: 'KG'
|
||||
}
|
||||
]) });
|
||||
|
||||
assert.equal(state.compositions.length, 1);
|
||||
assert.equal(state.compositionComponents.length, 1);
|
||||
assert.equal(state.compositionComponents[0].quantity_per_unit, 0.2);
|
||||
assert.equal(state.productionOrders.length, 0);
|
||||
});
|
||||
});
|
||||
|
||||
test('product composition detail lookup returns stored components', async () => {
|
||||
await withFakeDatabase(async () => {
|
||||
await invokeHandler(tinySyncHandler, { body: compositionPayload() });
|
||||
const response = await invokeHandler(productCompositionHandler, { params: { productId: '976058813' } });
|
||||
const composition = response.body.composition;
|
||||
|
||||
assert.equal(response.statusCode, 200);
|
||||
assert.equal(composition.finishedProductSku, 'BLCS.CAF.GG');
|
||||
assert.equal(composition.components.length, 2);
|
||||
assert.deepEqual(composition.components[0], {
|
||||
id: 1,
|
||||
componentTinyId: '919498232',
|
||||
componentSku: 'ETIQ.T.GG',
|
||||
componentName: 'ETIQUETA DE TAMANHO GG',
|
||||
quantityPerUnit: 1,
|
||||
unit: 'UN',
|
||||
productId: null
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('product composition export endpoint returns every stored composition', async () => {
|
||||
await withFakeDatabase(async () => {
|
||||
await invokeHandler(tinySyncHandler, { body: compositionPayload() });
|
||||
const response = await invokeHandler(productCompositionsHandler, { params: {} });
|
||||
|
||||
assert.equal(response.statusCode, 200);
|
||||
assert.equal(response.body.compositions.length, 1);
|
||||
assert.equal(response.body.compositions[0].components.length, 2);
|
||||
});
|
||||
});
|
||||
|
||||
test('ordinary Tiny production-order payloads continue to create production orders', async () => {
|
||||
await withFakeDatabase(async (state) => {
|
||||
const response = await invokeHandler(tinySyncHandler, { body: {
|
||||
order: {
|
||||
tinyId: '12345', number: 'OP-12345', status: 'in_progress', productSku: 'BLCS.CAF.GG',
|
||||
productDescription: 'BASE LISA CAMISETA COR CAFE TAMANHO - GG', quantity: '10', unit: 'UN'
|
||||
},
|
||||
components: [],
|
||||
steps: []
|
||||
} });
|
||||
const result = response.body;
|
||||
|
||||
assert.equal(response.statusCode, 201);
|
||||
assert.equal(state.compositions.length, 0);
|
||||
assert.equal(state.productionOrders.length, 1);
|
||||
assert.equal(result.order.tinyId, '12345');
|
||||
assert.equal(result.order.number, 'OP-12345');
|
||||
});
|
||||
});
|
||||
@@ -27,6 +27,11 @@ services:
|
||||
- ADMIN_PASSWORD=${ADMIN_PASSWORD:-admin123}
|
||||
- JWT_SECRET=${JWT_SECRET:-super_secret_jwt_key_123}
|
||||
- N8N_WHATSAPP_TRIGGER_URL=${N8N_WHATSAPP_TRIGGER_URL:-http://localhost:5678/webhook/whatsapp}
|
||||
- TURNSTILE_SITE_KEY=${TURNSTILE_SITE_KEY:-}
|
||||
- TURNSTILE_SITEKEY=${TURNSTILE_SITEKEY:-}
|
||||
- VITE_TURNSTILE_SITE_KEY=${VITE_TURNSTILE_SITE_KEY:-}
|
||||
- TURNSTILE_SECRET=${TURNSTILE_SECRET:-}
|
||||
- TURNSTILE_SECRET_KEY=${TURNSTILE_SECRET_KEY:-}
|
||||
depends_on:
|
||||
- db
|
||||
restart: unless-stopped
|
||||
|
||||
@@ -10,10 +10,13 @@ const ProductDetails = React.lazy(() => import('./pages/ProductDetails'));
|
||||
const ProductGroupDetails = React.lazy(() => import('./pages/ProductGroupDetails'));
|
||||
const Replenishment = React.lazy(() => import('./pages/Replenishment'));
|
||||
const Cutting = React.lazy(() => import('./pages/Cutting'));
|
||||
const PlanningIssues = React.lazy(() => import('./pages/PlanningIssues'));
|
||||
const ProductionOrders = React.lazy(() => import('./pages/ProductionOrders'));
|
||||
const Supplies = React.lazy(() => import('./pages/Supplies'));
|
||||
const Clients = React.lazy(() => import('./pages/Clients'));
|
||||
const ClientDetails = React.lazy(() => import('./pages/ClientDetails'));
|
||||
const Sellers = React.lazy(() => import('./pages/Sellers'));
|
||||
const SellerDetails = React.lazy(() => import('./pages/SellerDetails'));
|
||||
const Campaigns = React.lazy(() => import('./pages/Campaigns'));
|
||||
const Rfm = React.lazy(() => import('./pages/Rfm'));
|
||||
const Registrations = React.lazy(() => import('./pages/Registrations'));
|
||||
@@ -54,6 +57,7 @@ function App() {
|
||||
<Route path="products/:id" element={<ProductDetails />} />
|
||||
<Route path="replenishment" element={<Replenishment />} />
|
||||
<Route path="cutting" element={<Cutting />} />
|
||||
<Route path="planning-issues" element={<PlanningIssues />} />
|
||||
<Route path="supplies" element={<Supplies />} />
|
||||
<Route path="supplies/:section" element={<Supplies />} />
|
||||
<Route path="stock" element={<Navigate to="/products" replace />} />
|
||||
@@ -61,6 +65,8 @@ function App() {
|
||||
<Route path="production-orders" element={<ProductionOrders />} />
|
||||
<Route path="clients" element={<Clients />} />
|
||||
<Route path="clients/:clientToken" element={<ClientDetails />} />
|
||||
<Route path="sellers" element={<Sellers />} />
|
||||
<Route path="sellers/:sellerId" element={<SellerDetails />} />
|
||||
<Route path="rfm" element={<Rfm />} />
|
||||
<Route path="campaigns" element={<Campaigns />} />
|
||||
<Route path="registrations" element={<Registrations />} />
|
||||
|
||||
@@ -80,11 +80,11 @@ test('buildCutPlan estimates rolls when a family yield is configured', () => {
|
||||
assert.equal(plan.summary.estimatedRolls, 3);
|
||||
});
|
||||
|
||||
test('buildCutPlan applies per-product planning overrides', () => {
|
||||
test('buildCutPlan applies per-product planning overrides to finished apparel', () => {
|
||||
const plan = buildCutPlan([
|
||||
product({
|
||||
id: 'SKU-2',
|
||||
name: 'BONÉ PRETO',
|
||||
name: 'CAMISETA SEM PADRAO',
|
||||
quantitySold: 70,
|
||||
stock: 0
|
||||
})
|
||||
@@ -102,6 +102,51 @@ test('buildCutPlan applies per-product planning overrides', () => {
|
||||
assert.deepEqual(plan.needRows[0].issues, []);
|
||||
});
|
||||
|
||||
test('buildCutPlan excludes non-apparel products from cut planning', () => {
|
||||
const plan = buildCutPlan([
|
||||
product({
|
||||
id: 'SKU-2',
|
||||
name: 'BONÉ PRETO',
|
||||
quantitySold: 70,
|
||||
stock: 0
|
||||
}),
|
||||
product({
|
||||
id: 'SKU-3',
|
||||
name: 'TRANSPARENTE PP MILHEIRO - 25x35',
|
||||
quantitySold: 70,
|
||||
stock: 0
|
||||
})
|
||||
], range, 7);
|
||||
|
||||
assert.equal(plan.rows.length, 0);
|
||||
assert.equal(plan.summary.skuCount, 0);
|
||||
});
|
||||
|
||||
test('buildCutPlan includes non-apparel products manually marked as finished apparel', () => {
|
||||
const plan = buildCutPlan([
|
||||
product({
|
||||
id: 'SKU-2',
|
||||
name: 'BONÉ PRETO',
|
||||
quantitySold: 70,
|
||||
stock: 0
|
||||
})
|
||||
], range, 7, {}, {
|
||||
familyYields: { BLCS: 35 },
|
||||
productOverrides: {
|
||||
'SKU-2': {
|
||||
productType: 'finished_apparel',
|
||||
familyKey: 'BLCS',
|
||||
color: 'Preto',
|
||||
size: 'M'
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
assert.equal(plan.needRows.length, 1);
|
||||
assert.equal(plan.needRows[0].family.key, 'BLCS');
|
||||
assert.equal(plan.needRows[0].suggestedCutQuantity, 70);
|
||||
});
|
||||
|
||||
test('buildOpenProductionByProductId matches open OPs by SKU and normalized product variant', () => {
|
||||
const products = [
|
||||
product({ id: 'SKU-1' }),
|
||||
@@ -116,11 +161,11 @@ test('buildOpenProductionByProductId matches open OPs by SKU and normalized prod
|
||||
assert.deepEqual(totals, { 'SKU-1': 10, 'SKU-2': 20 });
|
||||
});
|
||||
|
||||
test('buildCutPlan marks products that cannot be planned cleanly for cutting', () => {
|
||||
test('buildCutPlan marks apparel products that cannot be planned cleanly for cutting', () => {
|
||||
const plan = buildCutPlan([
|
||||
product({
|
||||
id: 'SKU-2',
|
||||
name: 'BONÉ PRETO',
|
||||
name: 'VESTUARIO SEM PADRAO',
|
||||
quantitySold: 70,
|
||||
stock: 0
|
||||
})
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { CutFamilyKey, CutProductOverride, DateRange, ProductAnalyticsItem, ProductionOrderItem } from '../types';
|
||||
import { normalizeProductText, parseProductName, sortProductSizes } from '../productParsing.ts';
|
||||
import { resolveProductType } from '../productClassification.ts';
|
||||
import { getPlanningStock } from '../planningStock.ts';
|
||||
|
||||
export type { CutFamilyKey, CutProductOverride };
|
||||
|
||||
@@ -201,8 +203,11 @@ export const buildCutPlan = (
|
||||
options: CutPlanOptions = {}
|
||||
): CutPlan => {
|
||||
const rangeDays = getRangeDays(dateRange);
|
||||
const cuttableProducts = products.filter(product => (
|
||||
resolveProductType(product.name, options.productOverrides?.[product.id]) === 'finished_apparel'
|
||||
));
|
||||
|
||||
const rows = products.map<CutPlanSkuRow>(product => {
|
||||
const rows = cuttableProducts.map<CutPlanSkuRow>(product => {
|
||||
const metadata = parseProductName(product.name);
|
||||
const override = options.productOverrides?.[product.id];
|
||||
const overrideRule = override?.familyKey ? FAMILY_RULES_BY_KEY.get(override.familyKey) : undefined;
|
||||
@@ -212,7 +217,7 @@ export const buildCutPlan = (
|
||||
const dailySales = product.quantitySold / rangeDays;
|
||||
const projectedDemand = dailySales * targetCoverageDays;
|
||||
const openProductionQuantity = openProductionByProductId[product.id] || 0;
|
||||
const availableQuantity = product.stock + openProductionQuantity;
|
||||
const availableQuantity = getPlanningStock(product.stock) + openProductionQuantity;
|
||||
const suggestedCutQuantity = Math.max(0, Math.ceil(projectedDemand - availableQuantity));
|
||||
const estimatedRolls = family.unitsPerRoll && suggestedCutQuantity > 0
|
||||
? Math.ceil(suggestedCutQuantity / family.unitsPerRoll)
|
||||
|
||||
@@ -94,12 +94,12 @@ const DateRangePicker: React.FC<DateRangePickerProps> = ({ dateRange, onChange,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<div className="flex w-full flex-col gap-2 sm:w-auto sm:flex-row sm:flex-wrap sm:items-center sm:gap-3">
|
||||
{/* Date Presets Dropdown */}
|
||||
<div className="relative">
|
||||
<div className="relative w-full sm:w-auto">
|
||||
<button
|
||||
onClick={() => setIsPresetOpen(!isPresetOpen)}
|
||||
className="flex items-center gap-2 bg-dark-card border border-dark-border px-4 py-2.5 rounded-xl shadow-sm hover:border-brand-primary transition-colors text-sm font-medium text-dark-text cursor-pointer"
|
||||
className="flex min-h-11 w-full items-center gap-2 rounded-xl border border-dark-border bg-dark-card px-4 py-2.5 text-sm font-medium text-dark-text shadow-sm transition-colors hover:border-brand-primary cursor-pointer sm:w-auto"
|
||||
>
|
||||
<Calendar size={16} className="text-dark-muted shrink-0" />
|
||||
<span>{formatDisplayDate(dateRange.start)} - {formatDisplayDate(dateRange.end)}</span>
|
||||
@@ -109,7 +109,7 @@ const DateRangePicker: React.FC<DateRangePickerProps> = ({ dateRange, onChange,
|
||||
{isPresetOpen && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-10" onClick={() => setIsPresetOpen(false)}></div>
|
||||
<div className="absolute right-0 mt-2 w-56 bg-dark-card border border-dark-border rounded-xl shadow-xl z-20 py-2 max-h-[32rem] overflow-y-auto">
|
||||
<div className="fixed inset-x-3 top-20 z-20 mt-0 max-h-[calc(100dvh-6rem)] overflow-y-auto rounded-xl border border-dark-border bg-dark-card py-2 shadow-xl sm:absolute sm:right-0 sm:top-auto sm:inset-x-auto sm:mt-2 sm:max-h-[32rem] sm:w-56">
|
||||
<div className="px-4 pb-3 pt-1 border-b border-dark-border mb-2 flex flex-col gap-2">
|
||||
<span className="text-xs font-bold text-dark-muted uppercase tracking-widest">Período Customizado</span>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
@@ -177,7 +177,7 @@ const DateRangePicker: React.FC<DateRangePickerProps> = ({ dateRange, onChange,
|
||||
|
||||
{/* Auto Refresh Dropdown */}
|
||||
{setRefreshInterval && onManualRefresh && (
|
||||
<div className="flex items-center bg-dark-card border border-dark-border rounded-xl shadow-sm overflow-hidden hover:border-brand-primary transition-colors">
|
||||
<div className="flex min-h-11 w-full items-center overflow-hidden rounded-xl border border-dark-border bg-dark-card shadow-sm transition-colors hover:border-brand-primary sm:w-auto">
|
||||
<button
|
||||
onClick={onManualRefresh}
|
||||
className="flex items-center gap-1.5 px-4 py-2.5 text-sm font-medium text-dark-text hover:text-brand-primary hover:bg-dark-input transition-colors border-r border-dark-border cursor-pointer"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Outlet, Link, useLocation } from 'react-router-dom';
|
||||
import { LayoutDashboard, Users, BarChart3, ChevronLeft, ChevronRight, Package, LogOut, Megaphone, Grid3X3, Shield, Moon, Sun, Tags, Boxes } from 'lucide-react';
|
||||
import { LayoutDashboard, Users, BarChart3, ChevronLeft, ChevronRight, Package, LogOut, Megaphone, Grid3X3, Shield, Moon, Sun, Tags, Boxes, ClipboardList, Menu, X, BriefcaseBusiness } from 'lucide-react';
|
||||
import type { DateRange, OrderData } from '../types';
|
||||
import { isSuperAdmin, logout } from '../dataService';
|
||||
import { rangeForLastDays } from '../dateRanges';
|
||||
@@ -13,6 +13,7 @@ const Layout = () => {
|
||||
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(() => {
|
||||
return localStorage.getItem('graph_sidebar_collapsed') === 'true';
|
||||
});
|
||||
const [isMobileNavOpen, setIsMobileNavOpen] = useState(false);
|
||||
const [themeMode, setThemeMode] = useState<ThemeMode>(() => {
|
||||
return localStorage.getItem('nexstar_theme') === 'offwhite' ? 'offwhite' : 'dark';
|
||||
});
|
||||
@@ -58,6 +59,7 @@ const Layout = () => {
|
||||
const toggleTheme = () => {
|
||||
setThemeMode(current => current === 'dark' ? 'offwhite' : 'dark');
|
||||
};
|
||||
const showSidebarLabels = !isSidebarCollapsed || isMobileNavOpen;
|
||||
|
||||
const adminNavigation = isSuperAdmin()
|
||||
? [{ name: 'Usuários', href: '/admin/users', icon: Shield }]
|
||||
@@ -73,6 +75,7 @@ const Layout = () => {
|
||||
label: 'Operação',
|
||||
items: [
|
||||
{ name: 'Produtos', href: '/products', icon: Package },
|
||||
{ name: 'Dados Pendentes', href: '/planning-issues', icon: ClipboardList },
|
||||
{ name: 'Cadastros', href: '/registrations', icon: Tags },
|
||||
{ name: 'Suprimentos', href: '/supplies', icon: Boxes },
|
||||
],
|
||||
@@ -81,6 +84,7 @@ const Layout = () => {
|
||||
label: 'Comercial',
|
||||
items: [
|
||||
{ name: 'Clientes', href: '/clients', icon: Users },
|
||||
{ name: 'Vendedores', href: '/sellers', icon: BriefcaseBusiness },
|
||||
{ name: 'RFV', href: '/rfm', icon: Grid3X3 },
|
||||
{ name: 'Campanhas', href: '/campaigns', icon: Megaphone },
|
||||
],
|
||||
@@ -89,25 +93,36 @@ const Layout = () => {
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="app-shell flex h-screen text-dark-text overflow-hidden">
|
||||
<div className="app-shell flex h-dvh min-w-0 text-dark-text overflow-hidden">
|
||||
{isMobileNavOpen && (
|
||||
<button
|
||||
type="button"
|
||||
className="fixed inset-0 z-40 bg-black/55 md:hidden"
|
||||
onClick={() => setIsMobileNavOpen(false)}
|
||||
aria-label="Fechar menu"
|
||||
/>
|
||||
)}
|
||||
{/* Sidebar */}
|
||||
<aside className={`bg-dark-sidebar border-r border-dark-border flex flex-col transition-all duration-300 ${isSidebarCollapsed ? 'w-20' : 'w-64'}`}>
|
||||
<div className={`h-20 px-6 border-b border-dark-border flex items-center ${isSidebarCollapsed ? 'justify-center' : 'justify-between'}`}>
|
||||
<aside className={`fixed inset-y-0 left-0 z-50 flex w-72 flex-col border-r border-dark-border bg-dark-sidebar transition-transform duration-300 md:relative md:z-auto md:translate-x-0 md:transition-[width] ${isSidebarCollapsed ? 'md:w-20' : 'md:w-64'} ${isMobileNavOpen ? 'translate-x-0' : '-translate-x-full'}`}>
|
||||
<div className={`flex h-16 items-center border-b border-dark-border px-5 md:h-20 md:px-6 ${isSidebarCollapsed ? 'md:justify-center' : 'justify-between'}`}>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="rounded-lg border border-brand-primary/25 bg-brand-primary/15 p-1.5 text-brand-primary shadow-sm shadow-brand-primary/10">
|
||||
<BarChart3 className="w-6 h-6" />
|
||||
</div>
|
||||
{!isSidebarCollapsed && <span className="text-xl font-bold text-dark-text">Nexstar</span>}
|
||||
{showSidebarLabels && <span className="text-xl font-bold text-dark-text">Nexstar</span>}
|
||||
</div>
|
||||
<button onClick={() => setIsMobileNavOpen(false)} className="text-dark-muted transition-colors hover:text-dark-text md:hidden" aria-label="Fechar menu">
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
{!isSidebarCollapsed && (
|
||||
<button onClick={toggleSidebar} className="text-dark-muted hover:text-dark-text transition-colors cursor-pointer">
|
||||
<button onClick={toggleSidebar} className="hidden cursor-pointer text-dark-muted transition-colors hover:text-dark-text md:block">
|
||||
<ChevronLeft className="w-5 h-5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isSidebarCollapsed && (
|
||||
<div className="p-4 border-b border-dark-border flex justify-center">
|
||||
<div className="hidden border-b border-dark-border p-4 md:flex md:justify-center">
|
||||
<button onClick={toggleSidebar} className="text-dark-muted hover:text-dark-text transition-colors cursor-pointer">
|
||||
<ChevronRight className="w-5 h-5" />
|
||||
</button>
|
||||
@@ -119,10 +134,10 @@ const Layout = () => {
|
||||
<div
|
||||
key={section.label}
|
||||
className={`space-y-1.5 ${
|
||||
isSidebarCollapsed ? 'border-t border-dark-border pt-5 first:border-t-0 first:pt-0' : ''
|
||||
isSidebarCollapsed ? 'md:border-t md:border-dark-border md:pt-5 md:first:border-t-0 md:first:pt-0' : ''
|
||||
}`}
|
||||
>
|
||||
{!isSidebarCollapsed && (
|
||||
{showSidebarLabels && (
|
||||
<div className="px-3 pb-1 text-[11px] font-bold uppercase tracking-widest text-dark-muted">
|
||||
{section.label}
|
||||
</div>
|
||||
@@ -133,8 +148,9 @@ const Layout = () => {
|
||||
<Link
|
||||
key={item.name}
|
||||
to={item.href}
|
||||
onClick={() => setIsMobileNavOpen(false)}
|
||||
className={`group flex min-h-11 items-center rounded-lg px-3 py-2.5 text-sm transition-all ${
|
||||
isSidebarCollapsed ? 'justify-center' : 'gap-3'
|
||||
isSidebarCollapsed ? 'gap-3 md:justify-center' : 'gap-3'
|
||||
} ${
|
||||
isActive
|
||||
? 'bg-brand-primary/12 text-brand-primary font-semibold shadow-sm shadow-brand-primary/10 ring-1 ring-brand-primary/15'
|
||||
@@ -143,7 +159,7 @@ const Layout = () => {
|
||||
title={isSidebarCollapsed ? item.name : undefined}
|
||||
>
|
||||
<item.icon className="h-4.5 w-4.5 shrink-0" />
|
||||
{!isSidebarCollapsed && <span className="truncate font-medium">{item.name}</span>}
|
||||
{showSidebarLabels && <span className="truncate font-medium">{item.name}</span>}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
@@ -154,23 +170,33 @@ const Layout = () => {
|
||||
<div className="p-4 border-t border-dark-border">
|
||||
<button
|
||||
onClick={logout}
|
||||
className={`w-full flex items-center text-red-500 hover:bg-red-500/10 px-4 py-3 rounded-xl transition-all cursor-pointer ${isSidebarCollapsed ? 'justify-center' : 'space-x-3'}`}
|
||||
className={`flex w-full items-center rounded-xl px-4 py-3 text-red-500 transition-all hover:bg-red-500/10 cursor-pointer ${isSidebarCollapsed ? 'gap-3 md:justify-center' : 'gap-3'}`}
|
||||
>
|
||||
<LogOut className="w-5 h-5 shrink-0" />
|
||||
{!isSidebarCollapsed && <span className="font-medium">Sair</span>}
|
||||
{showSidebarLabels && <span className="font-medium">Sair</span>}
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="flex-1 flex flex-col h-screen overflow-hidden">
|
||||
<main className="flex min-w-0 flex-1 flex-col overflow-hidden">
|
||||
{/* Header */}
|
||||
<header className="h-20 bg-dark-header border-b border-dark-border flex items-center justify-between px-8 shrink-0">
|
||||
<h2 className="text-xl font-bold text-dark-text">Painel de Análise</h2>
|
||||
<header className="flex h-16 shrink-0 items-center justify-between border-b border-dark-border bg-dark-header px-4 md:h-20 md:px-8">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsMobileNavOpen(true)}
|
||||
className="inline-flex h-11 w-11 shrink-0 items-center justify-center rounded-xl border border-dark-border bg-dark-input text-dark-text md:hidden"
|
||||
aria-label="Abrir menu"
|
||||
>
|
||||
<Menu className="h-5 w-5" />
|
||||
</button>
|
||||
<h2 className="truncate text-lg font-bold text-dark-text md:text-xl">Painel de Análise</h2>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleTheme}
|
||||
className="inline-flex h-10 w-10 items-center justify-center rounded-xl border border-transparent bg-transparent text-dark-text transition-colors hover:border-dark-border hover:bg-dark-input hover:text-brand-primary cursor-pointer"
|
||||
className="inline-flex h-11 w-11 shrink-0 items-center justify-center rounded-xl border border-transparent bg-transparent text-dark-text transition-colors hover:border-dark-border hover:bg-dark-input hover:text-brand-primary cursor-pointer"
|
||||
title={themeMode === 'dark' ? 'Usar modo off-white' : 'Usar modo escuro'}
|
||||
aria-label={themeMode === 'dark' ? 'Usar modo off-white' : 'Usar modo escuro'}
|
||||
>
|
||||
@@ -179,7 +205,7 @@ const Layout = () => {
|
||||
</header>
|
||||
|
||||
{/* Content Area */}
|
||||
<div className="app-content flex-1 overflow-y-auto p-8 relative">
|
||||
<div className="app-content relative flex-1 overflow-y-auto px-4 py-5 pb-24 sm:px-5 md:p-8">
|
||||
<Outlet context={{ dateRange, setDateRange, ordersData: emptyOrdersData, isDataLoading: false, refreshInterval, setRefreshInterval, themeMode }} />
|
||||
</div>
|
||||
</main>
|
||||
|
||||
21
src/components/ProductTypeBadge.tsx
Normal file
21
src/components/ProductTypeBadge.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import { getProductTypeConfig, type ProductTypeKey } from '../productClassification';
|
||||
|
||||
type ProductTypeBadgeProps = {
|
||||
type: ProductTypeKey;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const ProductTypeBadge = ({ type, className = '' }: ProductTypeBadgeProps) => {
|
||||
const config = getProductTypeConfig(type);
|
||||
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex w-fit items-center whitespace-nowrap rounded-full border px-2 py-0.5 text-[10px] font-bold ${config.badgeClassName} ${className}`}
|
||||
title={config.description}
|
||||
>
|
||||
{config.label}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProductTypeBadge;
|
||||
201
src/components/SkuPlanningModal.tsx
Normal file
201
src/components/SkuPlanningModal.tsx
Normal file
@@ -0,0 +1,201 @@
|
||||
import { useState } from 'react';
|
||||
import { RotateCcw, Save, X } from 'lucide-react';
|
||||
import { CUT_FAMILY_RULES, type CutFamilyKey } from '../analytics/cutting';
|
||||
import { editableProductTypeOptions, getProductTypeConfig, resolveProductType, type ProductTypeKey } from '../productClassification';
|
||||
import type { CutProductOverride } from '../types';
|
||||
import ProductTypeBadge from './ProductTypeBadge';
|
||||
|
||||
type SkuPlanningModalProduct = {
|
||||
id: string;
|
||||
name: string;
|
||||
color?: string;
|
||||
size?: string;
|
||||
};
|
||||
|
||||
type SkuPlanningModalProps = {
|
||||
product: SkuPlanningModalProduct;
|
||||
override?: CutProductOverride;
|
||||
isSaving?: boolean;
|
||||
onClose: () => void;
|
||||
onSave: (override: CutProductOverride | null) => void | Promise<void>;
|
||||
};
|
||||
|
||||
const familyOptions: Array<{ value: CutFamilyKey | ''; label: string }> = [
|
||||
{ value: '', label: 'Auto' },
|
||||
...CUT_FAMILY_RULES.map(rule => ({ value: rule.key, label: `${rule.materialLabel} · ${rule.label}` })),
|
||||
{ value: 'OUTROS', label: 'Sem regra' }
|
||||
];
|
||||
|
||||
const buildOverride = ({
|
||||
familyKey,
|
||||
color,
|
||||
size,
|
||||
productType,
|
||||
planningNotes
|
||||
}: Required<Pick<CutProductOverride, 'color' | 'size' | 'planningNotes'>> & {
|
||||
familyKey: CutFamilyKey | '';
|
||||
productType: ProductTypeKey | '';
|
||||
}): CutProductOverride | null => {
|
||||
const next: CutProductOverride = {
|
||||
familyKey,
|
||||
color: color.trim(),
|
||||
size: size.trim().toUpperCase(),
|
||||
productType,
|
||||
planningNotes: planningNotes.trim()
|
||||
};
|
||||
|
||||
if (!next.familyKey && !next.color && !next.size && !next.productType && !next.planningNotes) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return next;
|
||||
};
|
||||
|
||||
const SkuPlanningModal = ({ product, override, isSaving = false, onClose, onSave }: SkuPlanningModalProps) => {
|
||||
const [familyKey, setFamilyKey] = useState<CutFamilyKey | ''>(override?.familyKey || '');
|
||||
const [color, setColor] = useState(override?.color || '');
|
||||
const [size, setSize] = useState(override?.size || '');
|
||||
const [productType, setProductType] = useState<ProductTypeKey | ''>(override?.productType || '');
|
||||
const [planningNotes, setPlanningNotes] = useState(override?.planningNotes || '');
|
||||
|
||||
const resolvedType = resolveProductType(product.name, { productType });
|
||||
const resolvedConfig = getProductTypeConfig(resolvedType);
|
||||
|
||||
const handleSave = () => {
|
||||
void onSave(buildOverride({ familyKey, color, size, productType, planningNotes }));
|
||||
};
|
||||
|
||||
const handleClear = () => {
|
||||
void onSave(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex h-dvh items-center justify-center overflow-y-auto bg-zinc-950/80 p-4 backdrop-blur-md dark:bg-black/75"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={`Editar planejamento do SKU ${product.id}`}
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="w-full max-w-2xl rounded-2xl border border-dark-border bg-dark-card shadow-2xl"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4 border-b border-dark-border p-5">
|
||||
<div className="min-w-0">
|
||||
<div className="font-mono text-[10px] font-bold uppercase tracking-widest text-dark-muted">SKU #{product.id}</div>
|
||||
<h2 className="mt-1 truncate text-lg font-bold text-dark-text" title={product.name}>{product.name}</h2>
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2">
|
||||
<ProductTypeBadge type={resolvedType} />
|
||||
<span className="text-xs font-semibold text-dark-muted">{resolvedConfig.description}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="inline-flex h-9 w-9 shrink-0 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 cursor-pointer"
|
||||
title="Fechar"
|
||||
aria-label="Fechar"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 p-5 md:grid-cols-2">
|
||||
<label className="space-y-2">
|
||||
<span className="text-xs font-bold uppercase tracking-widest text-dark-muted">Tipo de produto</span>
|
||||
<select
|
||||
value={productType}
|
||||
onChange={(event) => setProductType(event.target.value as ProductTypeKey | '')}
|
||||
className="h-10 w-full rounded-lg border border-dark-border bg-dark-input px-3 text-sm font-bold text-dark-text outline-none transition-colors focus:border-brand-primary cursor-pointer"
|
||||
>
|
||||
<option value="">Auto ({getProductTypeConfig(resolveProductType(product.name)).label})</option>
|
||||
{editableProductTypeOptions.map(option => (
|
||||
<option key={option.value} value={option.value}>{option.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="space-y-2">
|
||||
<span className="text-xs font-bold uppercase tracking-widest text-dark-muted">Família de corte</span>
|
||||
<select
|
||||
value={familyKey}
|
||||
onChange={(event) => setFamilyKey(event.target.value as CutFamilyKey | '')}
|
||||
className="h-10 w-full rounded-lg border border-dark-border bg-dark-input px-3 text-sm font-bold text-dark-text outline-none transition-colors focus:border-brand-primary cursor-pointer"
|
||||
>
|
||||
{familyOptions.map(option => (
|
||||
<option key={option.value || 'auto'} value={option.value}>{option.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="space-y-2">
|
||||
<span className="text-xs font-bold uppercase tracking-widest text-dark-muted">Cor</span>
|
||||
<input
|
||||
type="text"
|
||||
value={color}
|
||||
onChange={(event) => setColor(event.target.value)}
|
||||
placeholder={product.color || 'Auto pelo nome'}
|
||||
className="h-10 w-full rounded-lg border border-dark-border bg-dark-input px-3 text-sm font-bold text-dark-text outline-none transition-colors placeholder:text-dark-muted focus:border-brand-primary"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="space-y-2">
|
||||
<span className="text-xs font-bold uppercase tracking-widest text-dark-muted">Tamanho</span>
|
||||
<input
|
||||
type="text"
|
||||
value={size}
|
||||
onChange={(event) => setSize(event.target.value)}
|
||||
placeholder={product.size || 'Auto pelo nome'}
|
||||
className="h-10 w-full rounded-lg border border-dark-border bg-dark-input px-3 text-sm font-bold uppercase text-dark-text outline-none transition-colors placeholder:normal-case placeholder:text-dark-muted focus:border-brand-primary"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="space-y-2 md:col-span-2">
|
||||
<span className="text-xs font-bold uppercase tracking-widest text-dark-muted">Notas de planejamento</span>
|
||||
<textarea
|
||||
value={planningNotes}
|
||||
onChange={(event) => setPlanningNotes(event.target.value)}
|
||||
rows={3}
|
||||
placeholder="Ex.: revisar consumo, material usado, regra temporária..."
|
||||
className="w-full resize-none rounded-lg border border-dark-border bg-dark-input px-3 py-2 text-sm font-semibold text-dark-text outline-none transition-colors placeholder:text-dark-muted focus:border-brand-primary"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 border-t border-dark-border p-5 sm:flex-row sm:items-center sm:justify-between">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClear}
|
||||
disabled={isSaving}
|
||||
className="inline-flex items-center justify-center gap-2 rounded-xl border border-dark-border bg-dark-input px-4 py-2.5 text-sm font-bold text-dark-muted transition-colors hover:border-red-400/40 hover:text-red-300 disabled:cursor-not-allowed disabled:opacity-50 cursor-pointer"
|
||||
>
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
Limpar override
|
||||
</button>
|
||||
<div className="flex gap-2 sm:justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
disabled={isSaving}
|
||||
className="inline-flex flex-1 items-center justify-center rounded-xl border border-dark-border bg-dark-input px-4 py-2.5 text-sm font-bold text-dark-text transition-colors hover:border-brand-primary disabled:cursor-not-allowed disabled:opacity-50 sm:flex-none cursor-pointer"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
disabled={isSaving}
|
||||
className="inline-flex flex-1 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-bold text-brand-primary transition-colors hover:border-brand-primary disabled:cursor-not-allowed disabled:opacity-50 sm:flex-none cursor-pointer"
|
||||
>
|
||||
<Save className="h-4 w-4" />
|
||||
{isSaving ? 'Salvando' : 'Salvar'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SkuPlanningModal;
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { AuthUser, CampaignPreview, CampaignProcessSummary, CampaignQueueSummary, CatalogCategory, CatalogCategoryPayload, CatalogProduct, CatalogProductPayload, CatalogSummary, ClientAnalyticsItem, ClientDetailsAnalytics, ClientFilterOptions, ClientMetadataFilters, ClientPurchasePatternAnalytics, ConsumptionReference, ConsumptionReferencePayload, CreateUserResult, CuttingSettings, DashboardAnalytics, DateRange, ManagedUser, OrderData, ProductAnalyticsItem, ProductDetailsAnalytics, ProductionOrderSummary, RfmAnalytics, StockData, SupplyFabricPlan, SupplyFabricPlanPayload, SupplyInventoryAdjustmentPayload, SupplyLot, SupplyProductionExitPayload, SupplyPurchaseNeed, SupplyReceipt, SupplyReceiptPayload, SupplySummary } from './types';
|
||||
import type { AuthUser, CampaignPreview, CampaignProcessSummary, CampaignQueueSummary, CatalogCategory, CatalogCategoryPayload, CatalogProduct, CatalogProductPayload, CatalogSummary, ClientAnalyticsItem, ClientDetailsAnalytics, ClientFilterOptions, ClientMetadataFilters, ClientPurchasePatternAnalytics, ConsumptionReference, ConsumptionReferencePayload, CreateProductionOrdersResult, CreateUserResult, CuttingSettings, DashboardAnalytics, DateRange, ManagedUser, OrderData, ProductAnalyticsItem, ProductComposition, ProductCompositionImportSummary, ProductDetailsAnalytics, ProductionOrderItem, ProductionOrderPayload, ProductionOrderStatus, ProductionOrderSummary, RfmAnalytics, SellerAnalyticsItem, SellerDetailsAnalytics, StockData, SupplyFabricPlan, SupplyFabricPlanPayload, SupplyInventoryAdjustmentPayload, SupplyLot, SupplyProductionExitPayload, SupplyPurchaseNeed, SupplyReceipt, SupplyReceiptPayload, SupplySummary } from './types';
|
||||
import { formatDateParam } from './dateRanges';
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL || '/api';
|
||||
@@ -55,27 +55,42 @@ const buildDateRangeParams = (dateRange: DateRange) => new URLSearchParams({
|
||||
end: formatDateParam(dateRange.end)
|
||||
});
|
||||
|
||||
export const login = async (email: string, password: string): Promise<boolean> => {
|
||||
export type LoginConfig = {
|
||||
captchaRequired: boolean;
|
||||
captchaConfigured: boolean;
|
||||
turnstileSiteKey: string;
|
||||
};
|
||||
|
||||
export type LoginResult = 'success' | 'invalid_credentials' | 'captcha_failed' | 'server_error';
|
||||
|
||||
export const getLoginConfig = async (): Promise<LoginConfig> => {
|
||||
const response = await fetch(`${API_URL}/login/config`, { cache: 'no-store' });
|
||||
if (!response.ok) throw new Error('Failed to load login config');
|
||||
return response.json() as Promise<LoginConfig>;
|
||||
};
|
||||
|
||||
export const login = async (email: string, password: string, captchaToken?: string): Promise<LoginResult> => {
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/login`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ email, password }),
|
||||
body: JSON.stringify({ email, password, captchaToken }),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json() as { token?: string; user?: AuthUser };
|
||||
if (!data.token || !data.user) return false;
|
||||
if (!data.token || !data.user) return 'server_error';
|
||||
localStorage.setItem('auth_token', data.token);
|
||||
localStorage.setItem('auth_user', JSON.stringify(data.user));
|
||||
return true;
|
||||
return 'success';
|
||||
}
|
||||
return false;
|
||||
if (response.status === 403) return 'captcha_failed';
|
||||
return 'invalid_credentials';
|
||||
} catch (error) {
|
||||
console.error('Login failed', error);
|
||||
return false;
|
||||
return 'server_error';
|
||||
}
|
||||
};
|
||||
|
||||
@@ -161,6 +176,23 @@ const authFetch = async (path: string, options: RequestInit = {}): Promise<Respo
|
||||
return response;
|
||||
};
|
||||
|
||||
export const downloadDatabaseDiagnostic = async (): Promise<void> => {
|
||||
const response = await authFetch('/admin/database-diagnostic', { cache: 'no-store' });
|
||||
const data = await response.json().catch(() => null);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data?.error || 'Não foi possível exportar o diagnóstico do banco.');
|
||||
}
|
||||
|
||||
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json;charset=utf-8;' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement('a');
|
||||
anchor.href = url;
|
||||
anchor.download = `graphs-db-diagnostic-${new Date().toISOString().slice(0, 10)}.json`;
|
||||
anchor.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
export const fetchProductionOrders = async (
|
||||
dateRange: DateRange,
|
||||
filters?: { search?: string },
|
||||
@@ -183,6 +215,30 @@ export const fetchProductionOrders = async (
|
||||
}, options);
|
||||
};
|
||||
|
||||
export const createProductionOrders = async (orders: ProductionOrderPayload[]): Promise<CreateProductionOrdersResult> => {
|
||||
const response = await authFetch('/production-orders', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ orders })
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Não foi possível criar as ordens de produção.');
|
||||
analyticsCache.clear();
|
||||
return await response.json();
|
||||
};
|
||||
|
||||
export const updateProductionOrderStatus = async (id: number, status: ProductionOrderStatus): Promise<ProductionOrderItem> => {
|
||||
const response = await authFetch(`/production-orders/${id}/status`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ status })
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Não foi possível atualizar o status da ordem.');
|
||||
analyticsCache.clear();
|
||||
return await response.json();
|
||||
};
|
||||
|
||||
export const fetchCuttingSettings = async (): Promise<CuttingSettings> => {
|
||||
try {
|
||||
const response = await authFetch('/cutting-settings');
|
||||
@@ -289,6 +345,22 @@ export const deleteConsumptionReference = async (id: number): Promise<void> => {
|
||||
}
|
||||
};
|
||||
|
||||
export const importProductCompositions = async (payload: unknown): Promise<ProductCompositionImportSummary> => {
|
||||
const response = await authFetch('/catalog/product-compositions/import', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
const data = await response.json().catch(() => null);
|
||||
if (!response.ok) {
|
||||
throw new Error(data?.error || 'Não foi possível importar as composições.');
|
||||
}
|
||||
|
||||
analyticsCache.clear();
|
||||
return data as ProductCompositionImportSummary;
|
||||
};
|
||||
|
||||
export const fetchSupplySummary = async (): Promise<SupplySummary> => {
|
||||
const emptySummary: SupplySummary = {
|
||||
receipts: [],
|
||||
@@ -469,6 +541,34 @@ export const fetchProductAnalytics = async (dateRange: DateRange): Promise<Produ
|
||||
});
|
||||
};
|
||||
|
||||
export const fetchSellerAnalytics = async (dateRange: DateRange): Promise<SellerAnalyticsItem[]> => {
|
||||
const path = `/analytics/sellers?${buildDateRangeParams(dateRange).toString()}`;
|
||||
return getCachedAnalytics(path, async () => {
|
||||
try {
|
||||
const response = await authFetch(path);
|
||||
if (!response.ok) return [];
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error('Fetch seller analytics failed', error);
|
||||
return [];
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const fetchSellerDetailsAnalytics = async (sellerId: string, dateRange: DateRange): Promise<SellerDetailsAnalytics | null> => {
|
||||
const path = `/analytics/sellers/${encodeURIComponent(sellerId)}/details?${buildDateRangeParams(dateRange).toString()}`;
|
||||
return getCachedAnalytics(path, async () => {
|
||||
try {
|
||||
const response = await authFetch(path);
|
||||
if (!response.ok) return null;
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error('Fetch seller details analytics failed', error);
|
||||
return null;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const fetchProductDetailsAnalytics = async (productId: string, dateRange: DateRange): Promise<ProductDetailsAnalytics | null> => {
|
||||
const path = `/analytics/products/${encodeURIComponent(productId)}/details?${buildDateRangeParams(dateRange).toString()}`;
|
||||
return getCachedAnalytics(path, async () => {
|
||||
@@ -483,6 +583,54 @@ export const fetchProductDetailsAnalytics = async (productId: string, dateRange:
|
||||
});
|
||||
};
|
||||
|
||||
export const fetchProductComposition = async (productId: string): Promise<ProductComposition | null> => {
|
||||
try {
|
||||
const response = await authFetch(`/analytics/products/${encodeURIComponent(productId)}/composition`);
|
||||
if (!response.ok) return null;
|
||||
const data = await response.json() as { composition?: ProductComposition | null };
|
||||
return data.composition || null;
|
||||
} catch (error) {
|
||||
console.error('Fetch product composition failed', error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const fetchProductCompositions = async (): Promise<ProductComposition[]> => {
|
||||
try {
|
||||
const response = await authFetch('/analytics/product-compositions');
|
||||
if (!response.ok) return [];
|
||||
const data = await response.json() as { compositions?: ProductComposition[] };
|
||||
return data.compositions || [];
|
||||
} catch (error) {
|
||||
console.error('Fetch product compositions failed', error);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
export const exportProductCompositions = async (): Promise<number> => {
|
||||
const response = await authFetch('/analytics/product-compositions');
|
||||
const data = await response.json().catch(() => null) as { compositions?: ProductComposition[]; error?: string } | null;
|
||||
if (!response.ok) {
|
||||
throw new Error(data?.error || 'Não foi possível exportar as composições.');
|
||||
}
|
||||
|
||||
const compositions = data?.compositions || [];
|
||||
const blob = new Blob([JSON.stringify({
|
||||
exportedAt: new Date().toISOString(),
|
||||
compositions
|
||||
}, null, 2)], { type: 'application/json;charset=utf-8' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = `composicoes_produtos_${new Date().toISOString().slice(0, 10)}.json`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
return compositions.length;
|
||||
};
|
||||
|
||||
const appendClientMetadataFilterParams = (params: URLSearchParams, filters?: Partial<ClientMetadataFilters>) => {
|
||||
if (!filters) return;
|
||||
if (filters.marketplace) params.set('marketplace', filters.marketplace);
|
||||
|
||||
@@ -149,6 +149,36 @@
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.34), rgba(255, 255, 255, 0) 16rem),
|
||||
transparent;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
min-width: 0;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.app-content {
|
||||
scroll-padding-bottom: 5rem;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.app-content .overflow-x-auto {
|
||||
overscroll-behavior-x: contain;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--color-dark-border) transparent;
|
||||
}
|
||||
|
||||
.app-content .overflow-x-auto::-webkit-scrollbar {
|
||||
height: 5px;
|
||||
}
|
||||
|
||||
.app-content input,
|
||||
.app-content select,
|
||||
.app-content textarea {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useMemo, useState, type FormEvent } from 'react';
|
||||
import {
|
||||
AlertTriangle,
|
||||
AtSign,
|
||||
Check,
|
||||
Copy,
|
||||
@@ -8,10 +9,13 @@ import {
|
||||
Loader2,
|
||||
Mail,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Search,
|
||||
Shield,
|
||||
Trash2,
|
||||
User as UserIcon,
|
||||
UserCheck,
|
||||
UserPlus,
|
||||
UserX,
|
||||
X
|
||||
} from 'lucide-react';
|
||||
import { createUser, deleteUser as deleteManagedUser, fetchUsers, updateUser } from '../dataService';
|
||||
@@ -31,25 +35,30 @@ const statusOptions: Array<{ key: StatusFilter; label: string }> = [
|
||||
{ key: 'inactive', label: 'Inativos' }
|
||||
];
|
||||
|
||||
const formatDateTime = (value: string) => {
|
||||
const date = new Date(value);
|
||||
if (!value || Number.isNaN(date.getTime())) return '-';
|
||||
|
||||
return new Intl.DateTimeFormat('pt-BR', {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
}).format(date);
|
||||
const roleLabels: Record<string, string> = {
|
||||
admin: 'Admin',
|
||||
supervisor: 'Supervisor',
|
||||
producao: 'Produção',
|
||||
operacao: 'Operação',
|
||||
vendedor: 'Vendedor',
|
||||
financeiro: 'Financeiro',
|
||||
user: 'Usuário'
|
||||
};
|
||||
|
||||
const inputWrapClassName = 'flex h-11 items-center gap-3 rounded-lg border border-dark-border bg-dark-input px-3 transition-colors focus-within:border-brand-primary focus-within:ring-1 focus-within:ring-brand-primary';
|
||||
const inputClassName = 'min-w-0 flex-1 bg-transparent text-sm font-semibold text-dark-text outline-none placeholder:text-dark-muted';
|
||||
|
||||
const getInitials = (name: string) => {
|
||||
const parts = name.trim().split(/\s+/).filter(Boolean);
|
||||
if (!parts.length) return '?';
|
||||
return parts.slice(0, 2).map((part) => part[0]).join('').toUpperCase();
|
||||
};
|
||||
|
||||
const getUserRoleLabel = (user: ManagedUser) => {
|
||||
const role = String((user as ManagedUser & { role?: string }).role || 'user').toLowerCase();
|
||||
return roleLabels[role] || role;
|
||||
};
|
||||
|
||||
const AdminUsers = () => {
|
||||
const [users, setUsers] = useState<ManagedUser[]>([]);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
@@ -87,6 +96,8 @@ const AdminUsers = () => {
|
||||
});
|
||||
}, [searchTerm, statusFilter, users]);
|
||||
|
||||
const hasFilters = searchTerm.trim() !== '' || statusFilter !== 'all';
|
||||
|
||||
const loadUsers = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
@@ -119,6 +130,11 @@ const AdminUsers = () => {
|
||||
resetForm();
|
||||
};
|
||||
|
||||
const clearFilters = () => {
|
||||
setSearchTerm('');
|
||||
setStatusFilter('all');
|
||||
};
|
||||
|
||||
const openCreateModal = () => {
|
||||
resetForm();
|
||||
setIsModalOpen(true);
|
||||
@@ -251,57 +267,99 @@ const AdminUsers = () => {
|
||||
? 'border-amber-500/30 bg-amber-500/10 text-amber-200'
|
||||
: 'border-emerald-500/30 bg-emerald-500/10 text-emerald-300';
|
||||
|
||||
const statusCounts: Record<StatusFilter, number> = {
|
||||
all: users.length,
|
||||
active: activeCount,
|
||||
inactive: inactiveCount
|
||||
};
|
||||
|
||||
const stats = [
|
||||
{ label: 'Total', value: users.length, icon: UserIcon, tone: 'text-dark-text', detail: 'acessos cadastrados' },
|
||||
{ label: 'Ativos', value: activeCount, icon: UserCheck, tone: 'text-emerald-300', detail: 'podem entrar agora' },
|
||||
{ label: 'Inativos', value: inactiveCount, icon: UserX, tone: 'text-red-300', detail: 'bloqueados no login' },
|
||||
{ label: 'Exibindo', value: filteredUsers.length, icon: Search, tone: 'text-brand-primary', detail: hasFilters ? 'após filtros' : 'sem filtros' }
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<header className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<div className="mb-2 flex items-center gap-2 text-sm font-semibold text-brand-primary">
|
||||
<Shield className="h-4 w-4" />
|
||||
Super admin
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-dark-text">Usuários</h1>
|
||||
<p className="mt-1 text-sm text-dark-muted">Controle quem acessa o painel.</p>
|
||||
<header className="flex flex-col gap-4 xl:flex-row xl:items-end xl:justify-between">
|
||||
<div className="max-w-3xl">
|
||||
<h1 className="text-3xl font-bold text-dark-text">Usuários</h1>
|
||||
<p className="mt-2 text-sm leading-6 text-dark-muted">
|
||||
Gerencie acessos individuais, senhas iniciais e bloqueios de entrada no painel.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void loadUsers()}
|
||||
disabled={isLoading}
|
||||
className="inline-flex h-10 cursor-pointer items-center justify-center gap-2 rounded-lg border border-dark-border bg-dark-card px-4 text-sm font-bold text-dark-text transition-colors hover:border-brand-primary disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 ${isLoading ? 'animate-spin' : ''}`} />
|
||||
Atualizar
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={openCreateModal}
|
||||
className="inline-flex h-10 cursor-pointer items-center justify-center gap-2 rounded-lg bg-brand-primary px-4 text-sm font-bold text-brand-contrast transition-colors hover:bg-brand-primary/90"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
Novo usuário
|
||||
<UserPlus className="h-4 w-4" />
|
||||
Novo acesso
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section className="rounded-lg border border-dark-border bg-dark-card">
|
||||
<div className="flex flex-col gap-4 border-b border-dark-border p-4 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div className="grid grid-cols-3 divide-x divide-dark-border overflow-hidden rounded-lg border border-dark-border bg-dark-input lg:w-[360px]">
|
||||
<div className="px-4 py-3">
|
||||
<p className="text-xs font-semibold text-dark-muted">Total</p>
|
||||
<p className="mt-1 text-xl font-bold text-dark-text">{users.length}</p>
|
||||
<section className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||
{stats.map((stat) => {
|
||||
const Icon = stat.icon;
|
||||
return (
|
||||
<div key={stat.label} className="rounded-2xl border border-dark-border bg-dark-card p-4 shadow-sm">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-xs font-bold uppercase tracking-widest text-dark-muted">{stat.label}</p>
|
||||
<p className={`mt-2 text-2xl font-bold ${stat.tone}`}>{stat.value}</p>
|
||||
<p className="mt-1 text-xs font-semibold text-dark-muted">{stat.detail}</p>
|
||||
</div>
|
||||
<div className="px-4 py-3">
|
||||
<p className="text-xs font-semibold text-dark-muted">Ativos</p>
|
||||
<p className="mt-1 text-xl font-bold text-emerald-300">{activeCount}</p>
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg border border-dark-border bg-dark-input text-dark-muted">
|
||||
<Icon className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="px-4 py-3">
|
||||
<p className="text-xs font-semibold text-dark-muted">Inativos</p>
|
||||
<p className="mt-1 text-xl font-bold text-red-300">{inactiveCount}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</section>
|
||||
|
||||
<section className="overflow-hidden rounded-2xl border border-dark-border bg-dark-card shadow-sm">
|
||||
<div className="border-b border-dark-border p-4">
|
||||
<div className="flex flex-col gap-4 xl:flex-row xl:items-center xl:justify-between">
|
||||
<div>
|
||||
<h2 className="text-base font-bold text-dark-text">Acessos cadastrados</h2>
|
||||
<p className="mt-1 text-sm text-dark-muted">Busque, filtre e edite os usuários do painel.</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-center">
|
||||
<div className="flex h-10 min-w-0 items-center gap-2 rounded-lg border border-dark-border bg-dark-input px-3 md:w-80">
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-center">
|
||||
<div className="flex h-10 min-w-0 items-center gap-2 rounded-lg border border-dark-border bg-dark-input px-3 lg:w-80">
|
||||
<Search className="h-4 w-4 shrink-0 text-dark-muted" />
|
||||
<input
|
||||
type="search"
|
||||
value={searchTerm}
|
||||
onChange={(event) => setSearchTerm(event.target.value)}
|
||||
className="min-w-0 flex-1 bg-transparent text-sm text-dark-text outline-none placeholder:text-dark-muted"
|
||||
className="min-w-0 flex-1 bg-transparent text-sm font-semibold text-dark-text outline-none placeholder:text-dark-muted"
|
||||
placeholder="Buscar nome ou e-mail"
|
||||
/>
|
||||
{searchTerm && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSearchTerm('')}
|
||||
className="flex h-6 w-6 cursor-pointer items-center justify-center rounded-md text-dark-muted transition-colors hover:bg-dark-card hover:text-dark-text"
|
||||
aria-label="Limpar busca"
|
||||
title="Limpar busca"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 rounded-lg border border-dark-border bg-dark-input p-1">
|
||||
@@ -317,63 +375,92 @@ const AdminUsers = () => {
|
||||
}`}
|
||||
>
|
||||
{option.label}
|
||||
<span className="ml-1 text-xs opacity-70">{statusCounts[option.key]}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex h-80 items-center justify-center text-brand-primary">
|
||||
<Loader2 className="h-7 w-7 animate-spin" />
|
||||
<div className="divide-y divide-dark-border" aria-label="Carregando usuários">
|
||||
{Array.from({ length: 5 }).map((_, index) => (
|
||||
<div key={`user-skeleton-${index}`} className="grid grid-cols-[1.4fr_1fr_120px_140px_88px] gap-5 px-5 py-4">
|
||||
{Array.from({ length: 5 }).map((__, column) => (
|
||||
<div key={`user-skeleton-${index}-${column}`} className="skeleton h-4" />
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : users.length === 0 ? (
|
||||
<div className="flex h-80 flex-col items-center justify-center px-6 text-center">
|
||||
<div className="mb-4 flex h-12 w-12 items-center justify-center rounded-lg bg-dark-input text-dark-muted">
|
||||
<UserIcon className="h-6 w-6" />
|
||||
<div className="flex min-h-[360px] flex-col items-center justify-center px-6 py-12 text-center">
|
||||
<div className="mb-5 flex h-14 w-14 items-center justify-center rounded-2xl border border-dark-border bg-dark-input text-brand-primary">
|
||||
<UserPlus className="h-7 w-7" />
|
||||
</div>
|
||||
<p className="text-base font-bold text-dark-text">Nenhum usuário cadastrado</p>
|
||||
<p className="mt-1 max-w-sm text-sm text-dark-muted">Crie o primeiro acesso para liberar o painel a outra pessoa.</p>
|
||||
<p className="text-lg font-bold text-dark-text">Nenhum usuário cadastrado</p>
|
||||
<p className="mt-2 max-w-md text-sm leading-6 text-dark-muted">Crie o primeiro acesso individual para tirar o uso compartilhado do login administrativo.</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={openCreateModal}
|
||||
className="mt-5 inline-flex h-10 cursor-pointer items-center justify-center gap-2 rounded-lg bg-brand-primary px-4 text-sm font-bold text-brand-contrast transition-colors hover:bg-brand-primary/90"
|
||||
>
|
||||
<UserPlus className="h-4 w-4" />
|
||||
Criar primeiro acesso
|
||||
</button>
|
||||
</div>
|
||||
) : filteredUsers.length === 0 ? (
|
||||
<div className="flex h-80 flex-col items-center justify-center px-6 text-center">
|
||||
<div className="mb-4 flex h-12 w-12 items-center justify-center rounded-lg bg-dark-input text-dark-muted">
|
||||
<Search className="h-6 w-6" />
|
||||
<div className="flex min-h-[320px] flex-col items-center justify-center px-6 py-12 text-center">
|
||||
<div className="mb-5 flex h-14 w-14 items-center justify-center rounded-2xl border border-dark-border bg-dark-input text-dark-muted">
|
||||
<Search className="h-7 w-7" />
|
||||
</div>
|
||||
<p className="text-base font-bold text-dark-text">Nenhum resultado</p>
|
||||
<p className="mt-1 text-sm text-dark-muted">Ajuste a busca ou limpe os filtros.</p>
|
||||
<p className="text-lg font-bold text-dark-text">Nenhum resultado</p>
|
||||
<p className="mt-2 max-w-md text-sm leading-6 text-dark-muted">Não encontramos usuários com os filtros atuais.</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={clearFilters}
|
||||
className="mt-5 inline-flex h-10 cursor-pointer items-center justify-center rounded-lg border border-dark-border px-4 text-sm font-bold text-dark-text transition-colors hover:border-brand-primary"
|
||||
>
|
||||
Limpar filtros
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[760px] text-left">
|
||||
<thead className="border-b border-dark-border text-xs uppercase tracking-widest text-dark-muted">
|
||||
<table className="w-full min-w-[900px] table-fixed text-left">
|
||||
<thead className="border-b border-dark-border bg-dark-input/35 text-xs uppercase tracking-widest text-dark-muted">
|
||||
<tr>
|
||||
<th className="px-5 py-3 font-bold">Usuário</th>
|
||||
<th className="px-5 py-3 font-bold">E-mail</th>
|
||||
<th className="px-5 py-3 font-bold">Status</th>
|
||||
<th className="px-5 py-3 text-right font-bold">Criado em</th>
|
||||
<th className="px-5 py-3 text-right font-bold">Ações</th>
|
||||
<th className="w-[34%] px-5 py-3 font-bold">Usuário</th>
|
||||
<th className="w-[24%] px-5 py-3 font-bold">E-mail</th>
|
||||
<th className="w-[16%] px-5 py-3 font-bold">Perfil</th>
|
||||
<th className="w-[14%] px-5 py-3 font-bold">Status</th>
|
||||
<th className="w-[12%] px-5 py-3 text-right font-bold">Ações</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-dark-border">
|
||||
{filteredUsers.map((user) => (
|
||||
<tr key={user.id} className="text-sm transition-colors hover:bg-dark-input/45">
|
||||
<td className="px-5 py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-brand-primary/10 text-sm font-bold text-brand-primary">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-brand-primary/10 text-sm font-bold text-brand-primary">
|
||||
{getInitials(user.name)}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-semibold text-dark-text">{user.name}</p>
|
||||
<p className="truncate font-bold text-dark-text">{user.name}</p>
|
||||
<p className="mt-0.5 text-xs font-semibold text-dark-muted">ID {user.id}</p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-5 py-4">
|
||||
<div className="flex min-w-0 items-center gap-2 text-dark-muted">
|
||||
<Mail className="h-4 w-4 shrink-0" />
|
||||
<span className="truncate">{user.email}</span>
|
||||
<span className="truncate font-semibold">{user.email}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-5 py-4">
|
||||
<span className="inline-flex rounded-full border border-brand-primary/20 bg-brand-primary/10 px-2.5 py-1 text-xs font-bold text-brand-primary">
|
||||
{getUserRoleLabel(user)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-5 py-4">
|
||||
<span className={`inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-bold ${
|
||||
user.isActive ? 'bg-emerald-500/10 text-emerald-300' : 'bg-red-500/10 text-red-300'
|
||||
@@ -382,13 +469,12 @@ const AdminUsers = () => {
|
||||
{user.isActive ? 'Ativo' : 'Inativo'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-5 py-4 text-right text-dark-muted">{formatDateTime(user.createdAt)}</td>
|
||||
<td className="px-5 py-4">
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openEditModal(user)}
|
||||
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg border border-dark-border text-dark-muted transition-colors hover:border-brand-primary/50 hover:text-brand-primary"
|
||||
className="flex h-9 w-9 cursor-pointer items-center justify-center rounded-lg border border-dark-border text-dark-muted transition-colors hover:border-brand-primary/50 hover:text-brand-primary"
|
||||
aria-label={`Editar ${user.name}`}
|
||||
title="Editar"
|
||||
>
|
||||
@@ -400,7 +486,7 @@ const AdminUsers = () => {
|
||||
setNotice(null);
|
||||
setUserToDelete(user);
|
||||
}}
|
||||
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg border border-dark-border text-dark-muted transition-colors hover:border-red-400/50 hover:text-red-300"
|
||||
className="flex h-9 w-9 cursor-pointer items-center justify-center rounded-lg border border-dark-border text-dark-muted transition-colors hover:border-red-400/50 hover:text-red-300"
|
||||
aria-label={`Excluir ${user.name}`}
|
||||
title="Excluir"
|
||||
>
|
||||
@@ -417,15 +503,20 @@ const AdminUsers = () => {
|
||||
</section>
|
||||
|
||||
{isModalOpen && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 px-4 py-6">
|
||||
<div className="w-full max-w-lg overflow-hidden rounded-lg border border-dark-border bg-dark-card shadow-2xl">
|
||||
<div className="flex items-start justify-between border-b border-dark-border px-5 py-4">
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center overflow-y-auto bg-black/70 px-4 py-6">
|
||||
<div className="w-full max-w-2xl overflow-hidden rounded-2xl border border-dark-border bg-dark-card shadow-2xl">
|
||||
<div className="flex items-start justify-between gap-4 border-b border-dark-border px-5 py-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl border border-dark-border bg-dark-input text-brand-primary">
|
||||
{editingUser ? <Edit3 className="h-5 w-5" /> : <UserPlus className="h-5 w-5" />}
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-dark-text">{editingUser ? 'Editar usuário' : 'Novo usuário'}</h2>
|
||||
<p className="mt-1 text-sm text-dark-muted">
|
||||
{editingUser ? 'Atualize dados, status e senha de acesso.' : 'Crie um acesso individual para o painel.'}
|
||||
<h2 className="text-lg font-bold text-dark-text">{editingUser ? 'Editar usuário' : 'Novo acesso'}</h2>
|
||||
<p className="mt-1 text-sm leading-5 text-dark-muted">
|
||||
{editingUser ? 'Atualize identificação, status e senha.' : 'Crie uma credencial individual para o painel.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeModal}
|
||||
@@ -437,16 +528,23 @@ const AdminUsers = () => {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={editingUser ? handleUpdateUser : handleCreateUser} className="space-y-4 p-5">
|
||||
<form onSubmit={editingUser ? handleUpdateUser : handleCreateUser}>
|
||||
<div className="space-y-5 p-5">
|
||||
<section>
|
||||
<div className="mb-3">
|
||||
<h3 className="text-sm font-bold text-dark-text">Identificação</h3>
|
||||
<p className="mt-1 text-xs font-semibold text-dark-muted">Use um nome reconhecível e um e-mail individual.</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<label className="block">
|
||||
<span className="mb-2 block text-xs font-bold uppercase tracking-widest text-dark-muted">Nome</span>
|
||||
<div className="flex h-11 items-center gap-3 rounded-lg border border-dark-border bg-dark-input px-3 transition-colors focus-within:border-brand-primary focus-within:ring-1 focus-within:ring-brand-primary">
|
||||
<div className={inputWrapClassName}>
|
||||
<UserIcon className="h-4 w-4 shrink-0 text-dark-muted" />
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
className="min-w-0 flex-1 bg-transparent text-dark-text outline-none placeholder:text-dark-muted"
|
||||
className={inputClassName}
|
||||
placeholder="Nome completo"
|
||||
required
|
||||
autoFocus
|
||||
@@ -456,59 +554,88 @@ const AdminUsers = () => {
|
||||
|
||||
<label className="block">
|
||||
<span className="mb-2 block text-xs font-bold uppercase tracking-widest text-dark-muted">E-mail</span>
|
||||
<div className="flex h-11 items-center gap-3 rounded-lg border border-dark-border bg-dark-input px-3 transition-colors focus-within:border-brand-primary focus-within:ring-1 focus-within:ring-brand-primary">
|
||||
<div className={inputWrapClassName}>
|
||||
<AtSign className="h-4 w-4 shrink-0 text-dark-muted" />
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(event) => setEmail(event.target.value)}
|
||||
className="min-w-0 flex-1 bg-transparent text-dark-text outline-none placeholder:text-dark-muted"
|
||||
className={inputClassName}
|
||||
placeholder="usuario@empresa.com"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div>
|
||||
<span className="mb-2 block text-xs font-bold uppercase tracking-widest text-dark-muted">{editingUser ? 'Senha' : 'Senha inicial'}</span>
|
||||
<div className="grid grid-cols-2 rounded-lg border border-dark-border bg-dark-input p-1">
|
||||
<section>
|
||||
<div className="mb-3">
|
||||
<h3 className="text-sm font-bold text-dark-text">{editingUser ? 'Senha' : 'Senha inicial'}</h3>
|
||||
<p className="mt-1 text-xs font-semibold text-dark-muted">
|
||||
{editingUser ? 'Mantenha a senha atual ou defina uma nova.' : 'Gere uma senha temporária ou defina uma senha manual.'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPasswordMode('auto')}
|
||||
className={`h-9 cursor-pointer rounded-md text-sm font-semibold transition-colors ${passwordMode === 'auto' ? 'bg-dark-card text-dark-text' : 'text-dark-muted hover:text-dark-text'}`}
|
||||
className={`cursor-pointer rounded-xl border p-4 text-left transition-colors ${
|
||||
passwordMode === 'auto'
|
||||
? 'border-brand-primary/50 bg-brand-primary/10 text-dark-text'
|
||||
: 'border-dark-border bg-dark-input text-dark-muted hover:border-brand-primary/35 hover:text-dark-text'
|
||||
}`}
|
||||
>
|
||||
{editingUser ? 'Manter' : 'Gerar'}
|
||||
<span className="flex items-center gap-2 text-sm font-bold">
|
||||
<KeyRound className="h-4 w-4" />
|
||||
{editingUser ? 'Manter senha' : 'Gerar senha'}
|
||||
</span>
|
||||
<span className="mt-1 block text-xs font-semibold opacity-80">
|
||||
{editingUser ? 'Não altera a credencial atual.' : 'Mostra uma senha para copiar após criar.'}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPasswordMode('manual')}
|
||||
className={`h-9 cursor-pointer rounded-md text-sm font-semibold transition-colors ${passwordMode === 'manual' ? 'bg-dark-card text-dark-text' : 'text-dark-muted hover:text-dark-text'}`}
|
||||
className={`cursor-pointer rounded-xl border p-4 text-left transition-colors ${
|
||||
passwordMode === 'manual'
|
||||
? 'border-brand-primary/50 bg-brand-primary/10 text-dark-text'
|
||||
: 'border-dark-border bg-dark-input text-dark-muted hover:border-brand-primary/35 hover:text-dark-text'
|
||||
}`}
|
||||
>
|
||||
{editingUser ? 'Alterar' : 'Definir'}
|
||||
<span className="flex items-center gap-2 text-sm font-bold">
|
||||
<Edit3 className="h-4 w-4" />
|
||||
{editingUser ? 'Alterar senha' : 'Definir senha'}
|
||||
</span>
|
||||
<span className="mt-1 block text-xs font-semibold opacity-80">
|
||||
Use quando a senha já será combinada fora do sistema.
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{passwordMode === 'manual' && (
|
||||
<div className="mt-3 flex h-11 items-center gap-3 rounded-lg border border-dark-border bg-dark-input px-3 transition-colors focus-within:border-brand-primary focus-within:ring-1 focus-within:ring-brand-primary">
|
||||
<div className="mt-3">
|
||||
<div className={inputWrapClassName}>
|
||||
<KeyRound className="h-4 w-4 shrink-0 text-dark-muted" />
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
className="min-w-0 flex-1 bg-transparent text-dark-text outline-none placeholder:text-dark-muted"
|
||||
className={inputClassName}
|
||||
placeholder="Mínimo de 6 caracteres"
|
||||
minLength={6}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{editingUser && (
|
||||
<label className="flex cursor-pointer items-center justify-between rounded-lg border border-dark-border bg-dark-input px-3 py-3">
|
||||
<label className="flex cursor-pointer items-center justify-between gap-4 rounded-xl border border-dark-border bg-dark-input px-4 py-3">
|
||||
<div>
|
||||
<span className="block text-sm font-semibold text-dark-text">Usuário ativo</span>
|
||||
<span className="mt-0.5 block text-xs text-dark-muted">Usuários inativos não conseguem entrar.</span>
|
||||
<span className="block text-sm font-bold text-dark-text">Usuário ativo</span>
|
||||
<span className="mt-0.5 block text-xs font-semibold text-dark-muted">Usuários inativos não conseguem entrar.</span>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -520,20 +647,20 @@ const AdminUsers = () => {
|
||||
)}
|
||||
|
||||
{notice && (
|
||||
<div className={`rounded-lg border px-3 py-3 text-sm ${noticeClass}`}>
|
||||
<div className={`rounded-xl border px-3 py-3 text-sm font-semibold ${noticeClass}`}>
|
||||
<div className="flex items-start gap-2">
|
||||
{notice.tone === 'error' ? <X className="mt-0.5 h-4 w-4 shrink-0" /> : <Check className="mt-0.5 h-4 w-4 shrink-0" />}
|
||||
<div className="min-w-0 flex-1">
|
||||
<p>{notice.text}</p>
|
||||
{notice.temporaryPassword && (
|
||||
<div className="mt-3 flex gap-2">
|
||||
<code className="min-w-0 flex-1 truncate rounded-md bg-black/25 px-3 py-2 text-amber-100">
|
||||
<div className="mt-3 flex flex-col gap-2 sm:flex-row">
|
||||
<code className="min-w-0 flex-1 truncate rounded-lg bg-black/25 px-3 py-2 text-amber-100">
|
||||
{notice.temporaryPassword}
|
||||
</code>
|
||||
<button
|
||||
type="button"
|
||||
onClick={copyTemporaryPassword}
|
||||
className="inline-flex h-9 cursor-pointer items-center gap-2 rounded-md border border-amber-400/30 px-3 text-xs font-bold text-amber-100 transition-colors hover:bg-amber-400/10"
|
||||
className="inline-flex h-9 cursor-pointer items-center justify-center gap-2 rounded-lg border border-amber-400/30 px-3 text-xs font-bold text-amber-100 transition-colors hover:bg-amber-400/10"
|
||||
>
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
{copyLabel}
|
||||
@@ -544,13 +671,14 @@ const AdminUsers = () => {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col-reverse gap-2 pt-2 sm:flex-row sm:justify-end">
|
||||
<div className="flex flex-col-reverse gap-2 border-t border-dark-border bg-dark-input/35 px-5 py-4 sm:flex-row sm:justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeModal}
|
||||
disabled={isSubmitting}
|
||||
className="h-10 cursor-pointer rounded-lg border border-dark-border px-4 text-sm font-semibold text-dark-muted transition-colors hover:text-dark-text disabled:cursor-not-allowed disabled:opacity-50"
|
||||
className="h-10 cursor-pointer rounded-lg border border-dark-border px-4 text-sm font-bold text-dark-muted transition-colors hover:text-dark-text disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
@@ -560,7 +688,7 @@ const AdminUsers = () => {
|
||||
className="inline-flex h-10 cursor-pointer items-center justify-center gap-2 rounded-lg bg-brand-primary px-4 text-sm font-bold text-brand-contrast transition-colors hover:bg-brand-primary/90 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{isSubmitting ? <Loader2 className="h-4 w-4 animate-spin" /> : <Plus className="h-4 w-4" />}
|
||||
{editingUser ? 'Salvar alterações' : 'Criar usuário'}
|
||||
{editingUser ? 'Salvar alterações' : 'Criar acesso'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -570,19 +698,24 @@ const AdminUsers = () => {
|
||||
|
||||
{userToDelete && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 px-4 py-6">
|
||||
<div className="w-full max-w-md overflow-hidden rounded-lg border border-dark-border bg-dark-card shadow-2xl">
|
||||
<div className="border-b border-dark-border px-5 py-4">
|
||||
<div className="w-full max-w-md overflow-hidden rounded-2xl border border-dark-border bg-dark-card shadow-2xl">
|
||||
<div className="flex items-start gap-3 border-b border-dark-border px-5 py-4">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl border border-red-500/25 bg-red-500/10 text-red-300">
|
||||
<AlertTriangle className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-dark-text">Excluir usuário</h2>
|
||||
<p className="mt-1 text-sm text-dark-muted">Esta ação remove o acesso imediatamente.</p>
|
||||
<p className="mt-1 text-sm leading-5 text-dark-muted">Esta ação remove o acesso imediatamente.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-4 p-5">
|
||||
<div className="rounded-lg border border-dark-border bg-dark-input p-4">
|
||||
<p className="font-semibold text-dark-text">{userToDelete.name}</p>
|
||||
<p className="mt-1 text-sm text-dark-muted">{userToDelete.email}</p>
|
||||
<div className="rounded-xl border border-dark-border bg-dark-input p-4">
|
||||
<p className="font-bold text-dark-text">{userToDelete.name}</p>
|
||||
<p className="mt-1 text-sm font-semibold text-dark-muted">{userToDelete.email}</p>
|
||||
</div>
|
||||
|
||||
{notice?.tone === 'error' && (
|
||||
<div className={`rounded-lg border px-3 py-3 text-sm ${noticeClass}`}>
|
||||
<div className={`rounded-xl border px-3 py-3 text-sm font-semibold ${noticeClass}`}>
|
||||
{notice.text}
|
||||
</div>
|
||||
)}
|
||||
@@ -596,7 +729,7 @@ const AdminUsers = () => {
|
||||
setNotice(null);
|
||||
}}
|
||||
disabled={isSubmitting}
|
||||
className="h-10 cursor-pointer rounded-lg border border-dark-border px-4 text-sm font-semibold text-dark-muted transition-colors hover:text-dark-text disabled:cursor-not-allowed disabled:opacity-50"
|
||||
className="h-10 cursor-pointer rounded-lg border border-dark-border px-4 text-sm font-bold text-dark-muted transition-colors hover:text-dark-text disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Link, useOutletContext } from 'react-router-dom';
|
||||
import { Search, ChevronRight, Filter, X } from 'lucide-react';
|
||||
import { Search, Eye, Filter, X } from 'lucide-react';
|
||||
import { BarChart, Bar, Cell, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
|
||||
import PaginationControls from '../components/PaginationControls';
|
||||
import RefreshStatus from '../components/RefreshStatus';
|
||||
@@ -794,10 +794,11 @@ const Clients = () => {
|
||||
<td className="px-6 py-2.5 text-right">
|
||||
<Link
|
||||
to={`/clients/${encodeURIComponent(client.clientToken)}`}
|
||||
className="inline-flex items-center text-xs font-bold text-brand-primary hover:opacity-80 transition-opacity cursor-pointer"
|
||||
className="inline-flex h-8 w-8 items-center justify-center rounded-lg bg-brand-primary/10 text-brand-primary transition-opacity hover:opacity-80 cursor-pointer"
|
||||
title={`Ver detalhes de ${client.name}`}
|
||||
aria-label={`Ver detalhes de ${client.name}`}
|
||||
>
|
||||
Ver detalhes
|
||||
<ChevronRight className="w-3.5 h-3.5 ml-1" />
|
||||
<Eye className="h-3.5 w-3.5" />
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Link, useOutletContext, useSearchParams } from 'react-router-dom';
|
||||
import { AlertTriangle, ArrowLeft, ClipboardList, Download, Eye, Layers3, Palette, Pencil, RotateCcw, Ruler, Save as SaveIcon, Scissors, Search, Settings2, X } from 'lucide-react';
|
||||
import { AlertTriangle, ArrowLeft, ClipboardList, Download, Eye, Layers3, Pencil, RotateCcw, Ruler, Save as SaveIcon, Scissors, Search, Settings2, X } from 'lucide-react';
|
||||
import DateRangePicker from '../components/DateRangePicker';
|
||||
import PaginationControls from '../components/PaginationControls';
|
||||
import ProductColorBadge from '../components/ProductColorBadge';
|
||||
import RefreshStatus from '../components/RefreshStatus';
|
||||
import { buildCuttingSkuConfigPath } from '../catalogLinks';
|
||||
import { CUT_FAMILY_RULES, buildCutPlan, buildOpenProductionByProductId, type CutFamilyKey, type CutIssue, type CutPlanSkuRow, type CutProductOverride } from '../analytics/cutting';
|
||||
import { exportToCSV, fetchCuttingSettings, fetchProductAnalytics, fetchProductionOrders, saveCuttingSettings } from '../dataService';
|
||||
import type { CuttingSettings, DateRange, ProductAnalyticsItem, ProductionOrderItem } from '../types';
|
||||
import { createProductionOrders, exportToCSV, fetchCuttingSettings, fetchProductAnalytics, fetchProductCompositions, fetchProductionOrders, fetchStock, saveCuttingSettings } from '../dataService';
|
||||
import type { CuttingSettings, DateRange, ProductAnalyticsItem, ProductComposition, ProductionOrderItem, StockData } from '../types';
|
||||
import { getPlanningStock } from '../planningStock';
|
||||
|
||||
type CutFilter = 'need' | 'all' | 'issues' | 'covered';
|
||||
type CutSort = 'need_desc' | 'need_asc' | 'demand_desc' | 'stock_asc' | 'sold_desc' | 'name_asc';
|
||||
@@ -16,6 +17,8 @@ type SaveStatus = 'idle' | 'saving' | 'saved' | 'error';
|
||||
type CutProductIssue = Exclude<CutIssue, 'missing_yield_rule'>;
|
||||
type CorrectionIssueFilter = CutProductIssue | 'all';
|
||||
type SettingsSection = 'rules' | 'corrections';
|
||||
type CuttingView = 'plan' | 'families' | 'issues';
|
||||
type MaterialReadiness = { status: 'ready' | 'material_ready' | 'blocked' | 'missing'; blockers: string[] };
|
||||
|
||||
const SETTINGS_STORAGE_KEY = 'nexstar_cutting_settings';
|
||||
const coverageTargetOptions = [7, 15, 30, 60];
|
||||
@@ -65,6 +68,22 @@ const formatNumber = (value: number, maximumFractionDigits = 0) => (
|
||||
new Intl.NumberFormat('pt-BR', { maximumFractionDigits }).format(value)
|
||||
);
|
||||
|
||||
const normalizeMaterialKey = (value: string) => value
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
.toUpperCase();
|
||||
|
||||
const isServiceComponent = (value: string) => /\b(?:SERVI[CÇ]O|FRETE|TINTURARIA|TECELAGEM|COSTURA|ESTAMPARIA|LAVANDERIA)\b/i.test(value);
|
||||
|
||||
const formatDateKey = (date: Date) => {
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
return `${year}-${month}-${day}`;
|
||||
};
|
||||
|
||||
const formatDays = (value: number | null) => {
|
||||
if (value === null) return '-';
|
||||
if (value > 999) return '999+ dias';
|
||||
@@ -120,6 +139,8 @@ const Cutting = () => {
|
||||
const [searchParams] = useSearchParams();
|
||||
const [products, setProducts] = useState<ProductAnalyticsItem[]>([]);
|
||||
const [productionOrders, setProductionOrders] = useState<ProductionOrderItem[]>([]);
|
||||
const [compositions, setCompositions] = useState<ProductComposition[]>([]);
|
||||
const [materialStock, setMaterialStock] = useState<StockData[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [targetCoverageDays, setTargetCoverageDays] = useState(30);
|
||||
@@ -135,6 +156,10 @@ const Cutting = () => {
|
||||
const [correctionPage, setCorrectionPage] = useState(1);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [itemsPerPage, setItemsPerPage] = useState(10);
|
||||
const [isGeneratingOrders, setIsGeneratingOrders] = useState(false);
|
||||
const [isOrderPreviewOpen, setIsOrderPreviewOpen] = useState(false);
|
||||
const [activeCuttingView, setActiveCuttingView] = useState<CuttingView>('plan');
|
||||
const [generationMessage, setGenerationMessage] = useState('');
|
||||
const cutConfigSection = searchParams.get('config');
|
||||
const targetCorrectionSku = (searchParams.get('sku') || '').trim();
|
||||
|
||||
@@ -169,13 +194,17 @@ const Cutting = () => {
|
||||
|
||||
const loadProducts = async () => {
|
||||
setIsLoading(true);
|
||||
const [productData, productionOrderData] = await Promise.all([
|
||||
const [productData, productionOrderData, compositionData, stockData] = await Promise.all([
|
||||
fetchProductAnalytics(dateRange),
|
||||
fetchProductionOrders(allProductionOrdersRange)
|
||||
fetchProductionOrders(allProductionOrdersRange),
|
||||
fetchProductCompositions(),
|
||||
fetchStock()
|
||||
]);
|
||||
if (isMounted) {
|
||||
setProducts(productData);
|
||||
setProductionOrders(productionOrderData.orders);
|
||||
setCompositions(compositionData);
|
||||
setMaterialStock(stockData);
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
@@ -197,6 +226,59 @@ const Cutting = () => {
|
||||
[cuttingSettings, dateRange, openProductionByProductId, products, targetCoverageDays]
|
||||
);
|
||||
|
||||
const materialReadinessByProductId = useMemo(() => {
|
||||
const compositionsById = new Map<string, ProductComposition>();
|
||||
compositions.forEach(composition => {
|
||||
[composition.finishedTinyProductId, composition.finishedProductSku]
|
||||
.map(normalizeMaterialKey)
|
||||
.filter(Boolean)
|
||||
.forEach(key => compositionsById.set(key, composition));
|
||||
});
|
||||
const stockIndexByKey = new Map<string, number>();
|
||||
materialStock.forEach((stock, index) => {
|
||||
[stock.produto_id, stock.nome].map(normalizeMaterialKey).filter(Boolean).forEach(key => stockIndexByKey.set(key, index));
|
||||
});
|
||||
const remainingStock = materialStock.map(stock => getPlanningStock(stock.saldo));
|
||||
const readinessByProductId = new Map<string, MaterialReadiness>();
|
||||
// Shared stock is allocated to the largest current requirement first. This
|
||||
// prevents every SKU from claiming the same material balance independently.
|
||||
[...cutPlan.rows].sort((a, b) => b.suggestedCutQuantity - a.suggestedCutQuantity || a.name.localeCompare(b.name, 'pt-BR')).forEach(row => {
|
||||
const composition = compositionsById.get(normalizeMaterialKey(row.id));
|
||||
if (!composition?.components.length) {
|
||||
readinessByProductId.set(row.id, { status: 'missing', blockers: [] });
|
||||
return;
|
||||
}
|
||||
const componentPlans = composition.components
|
||||
.filter(component => !isServiceComponent(component.componentName))
|
||||
.map(component => {
|
||||
const stockKeys = [component.componentTinyId, component.componentSku, component.productId || '', component.componentName].map(normalizeMaterialKey).filter(Boolean);
|
||||
const stockIndex = stockKeys.map(key => stockIndexByKey.get(key)).find(index => index !== undefined);
|
||||
const available = stockIndex === undefined ? null : remainingStock[stockIndex];
|
||||
const required = row.suggestedCutQuantity * component.quantityPerUnit;
|
||||
return { component, stockIndex, available, required };
|
||||
});
|
||||
if (!componentPlans.length) {
|
||||
readinessByProductId.set(row.id, { status: 'missing', blockers: ['Sem material produtivo na composição'] });
|
||||
return;
|
||||
}
|
||||
const blockers = componentPlans.flatMap(({ component, available, required }) => {
|
||||
if (!Number.isFinite(component.quantityPerUnit) || component.quantityPerUnit <= 0) return [`${component.componentName} (quantidade inválida)`];
|
||||
return available === null || available < required
|
||||
? [`${component.componentName}${available === null ? ' (sem estoque vinculado)' : ` (${formatNumber(available)} / ${formatNumber(required)} ${component.unit || ''})`}`]
|
||||
: [];
|
||||
});
|
||||
if (blockers.length) {
|
||||
readinessByProductId.set(row.id, { status: 'blocked', blockers });
|
||||
return;
|
||||
}
|
||||
componentPlans.forEach(({ stockIndex, required }) => {
|
||||
if (stockIndex !== undefined) remainingStock[stockIndex] -= required;
|
||||
});
|
||||
readinessByProductId.set(row.id, { status: row.issues.length === 0 ? 'ready' : 'material_ready', blockers: [] });
|
||||
});
|
||||
return readinessByProductId;
|
||||
}, [compositions, cutPlan.rows, materialStock]);
|
||||
|
||||
const issueSummaries = useMemo(() => {
|
||||
const counts = new Map<CutIssue, number>();
|
||||
cutPlan.needRows.forEach(row => {
|
||||
@@ -241,6 +323,32 @@ const Cutting = () => {
|
||||
});
|
||||
}, [cutFilter, cutPlan.rows, familyFilter, searchTerm, sortBy]);
|
||||
|
||||
const rowsAvailableForOrder = useMemo(() => (
|
||||
filteredRows.filter(row => row.suggestedCutQuantity > 0 && materialReadinessByProductId.get(row.id)?.status === 'ready')
|
||||
), [filteredRows, materialReadinessByProductId]);
|
||||
|
||||
const cutExecutionSummary = useMemo(() => {
|
||||
const rowsWithNeed = cutPlan.needRows.filter(row => row.suggestedCutQuantity > 0);
|
||||
const readyRows = rowsWithNeed.filter(row => materialReadinessByProductId.get(row.id)?.status === 'ready');
|
||||
const blockedByMaterial = rowsWithNeed.filter(row => {
|
||||
const status = materialReadinessByProductId.get(row.id)?.status;
|
||||
return status === 'blocked' || status === 'missing';
|
||||
});
|
||||
const blockedByData = rowsWithNeed.filter(row => materialReadinessByProductId.get(row.id)?.status === 'material_ready');
|
||||
return {
|
||||
rawUnits: rowsWithNeed.reduce((total, row) => total + row.suggestedCutQuantity, 0),
|
||||
readyUnits: readyRows.reduce((total, row) => total + row.suggestedCutQuantity, 0),
|
||||
readyRows: readyRows.length,
|
||||
blockedRows: blockedByMaterial.length + blockedByData.length,
|
||||
blockedByMaterial: blockedByMaterial.length,
|
||||
blockedByData: blockedByData.length,
|
||||
};
|
||||
}, [cutPlan.needRows, materialReadinessByProductId]);
|
||||
|
||||
const orderPreviewTotalUnits = useMemo(() => (
|
||||
rowsAvailableForOrder.reduce((total, row) => total + row.suggestedCutQuantity, 0)
|
||||
), [rowsAvailableForOrder]);
|
||||
|
||||
const totalPages = Math.ceil(filteredRows.length / itemsPerPage);
|
||||
const safeCurrentPage = Math.min(currentPage, totalPages || 1);
|
||||
const startIndex = (safeCurrentPage - 1) * itemsPerPage;
|
||||
@@ -289,12 +397,16 @@ const Cutting = () => {
|
||||
}, [correctionItemsPerPage, correctionRows, cutConfigSection, targetCorrectionSku]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isSettingsOpen) return undefined;
|
||||
if (!isSettingsOpen && !isOrderPreviewOpen) return undefined;
|
||||
|
||||
const closeOnEscape = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
if (isOrderPreviewOpen) {
|
||||
setIsOrderPreviewOpen(false);
|
||||
} else {
|
||||
setIsSettingsOpen(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const previousOverflow = document.body.style.overflow;
|
||||
@@ -304,7 +416,7 @@ const Cutting = () => {
|
||||
document.body.style.overflow = previousOverflow;
|
||||
document.removeEventListener('keydown', closeOnEscape);
|
||||
};
|
||||
}, [isSettingsOpen]);
|
||||
}, [isOrderPreviewOpen, isSettingsOpen]);
|
||||
|
||||
const updateFamilyYield = (familyKey: CutFamilyKey, value: string) => {
|
||||
const parsedValue = Number(value);
|
||||
@@ -396,10 +508,77 @@ const Cutting = () => {
|
||||
'Rendimento un/rolo': row.family.unitsPerRoll || '',
|
||||
'Rolos estimados': row.estimatedRolls || '',
|
||||
'Cobertura': row.daysOfCover === null ? '' : row.daysOfCover.toFixed(1).replace('.', ','),
|
||||
'Pronto para cortar': materialReadinessByProductId.get(row.id)?.status === 'ready' ? 'Sim' : materialReadinessByProductId.get(row.id)?.status === 'blocked' ? 'Não - falta material' : 'Sem composição',
|
||||
'Materiais bloqueando': materialReadinessByProductId.get(row.id)?.blockers.join(' | ') || '',
|
||||
'Dados pendentes': row.issues.map(issue => issueLabels[issue]).join(' | ')
|
||||
})), `plano_corte_${new Date().toISOString().split('T')[0]}.csv`);
|
||||
};
|
||||
|
||||
const openProductionOrderPreview = () => {
|
||||
if (!rowsAvailableForOrder.length) {
|
||||
setGenerationMessage('Não há SKU com necessidade e materiais disponíveis no filtro atual.');
|
||||
return;
|
||||
}
|
||||
|
||||
setGenerationMessage('');
|
||||
setIsOrderPreviewOpen(true);
|
||||
};
|
||||
|
||||
const generateProductionOrders = async () => {
|
||||
if (!rowsAvailableForOrder.length) {
|
||||
setGenerationMessage('Não há SKU com necessidade e materiais disponíveis no filtro atual.');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsGeneratingOrders(true);
|
||||
setGenerationMessage('');
|
||||
|
||||
const issueDate = formatDateKey(new Date());
|
||||
const orderReference = `Plano de corte ${formatDateKey(dateRange.start)} a ${formatDateKey(dateRange.end)} · ${targetCoverageDays} dias`;
|
||||
|
||||
try {
|
||||
const result = await createProductionOrders(rowsAvailableForOrder.map(row => ({
|
||||
status: 'open',
|
||||
orderReference,
|
||||
issueDate,
|
||||
productSku: row.id,
|
||||
productDescription: row.name,
|
||||
quantity: row.suggestedCutQuantity,
|
||||
unit: 'UN',
|
||||
integrationStatus: 'Local',
|
||||
markers: [
|
||||
{ label: row.family.materialLabel, color: '#38bdf8' },
|
||||
...(row.color ? [{ label: row.color, color: '#52DFA0' }] : []),
|
||||
{ label: 'Material confirmado', color: '#52DFA0' },
|
||||
...(row.issues.length ? [{ label: 'Dados pendentes', color: '#f59e0b' }] : [])
|
||||
],
|
||||
metadata: {
|
||||
source: 'cut_plan',
|
||||
familyKey: row.family.key,
|
||||
familyLabel: row.family.label,
|
||||
materialLabel: row.family.materialLabel,
|
||||
color: row.color,
|
||||
size: row.size,
|
||||
targetCoverageDays,
|
||||
projectedDemand: row.projectedDemand,
|
||||
stock: row.stock,
|
||||
openProductionQuantity: row.openProductionQuantity,
|
||||
availableQuantity: row.availableQuantity,
|
||||
issues: row.issues
|
||||
}
|
||||
})));
|
||||
|
||||
const refreshedOrders = await fetchProductionOrders(allProductionOrdersRange, undefined, { force: true });
|
||||
setProductionOrders(refreshedOrders.orders);
|
||||
setGenerationMessage(`${result.created.length} OPs criadas${result.skipped.length ? ` · ${result.skipped.length} já existiam abertas` : ''}.`);
|
||||
setIsOrderPreviewOpen(false);
|
||||
} catch (error) {
|
||||
setGenerationMessage(error instanceof Error ? error.message : 'Não foi possível gerar as ordens de produção.');
|
||||
} finally {
|
||||
setIsGeneratingOrders(false);
|
||||
}
|
||||
};
|
||||
|
||||
const renderIssueBadge = (row: CutPlanSkuRow) => {
|
||||
if (!row.issues.length) {
|
||||
return <span className="text-xs font-bold text-emerald-300">OK</span>;
|
||||
@@ -420,6 +599,14 @@ const Cutting = () => {
|
||||
);
|
||||
};
|
||||
|
||||
const renderMaterialReadiness = (row: CutPlanSkuRow) => {
|
||||
const readiness = materialReadinessByProductId.get(row.id);
|
||||
if (readiness?.status === 'ready') return <span className="text-xs font-bold text-emerald-300">Pode cortar</span>;
|
||||
if (readiness?.status === 'material_ready') return <span className="text-xs font-bold text-sky-300">Material OK · revisar dados</span>;
|
||||
if (readiness?.status === 'missing') return <span className="text-xs font-bold text-amber-300">Sem composição</span>;
|
||||
return <span className="inline-flex max-w-full rounded-full border border-red-400/30 bg-red-400/10 px-2 py-1 text-xs font-bold text-red-300" title={readiness?.blockers.join(' · ')}><span className="truncate">Falta: {readiness?.blockers[0] || 'material'}</span></span>;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="grid grid-cols-1 gap-4 2xl:grid-cols-[minmax(520px,1fr)_auto] 2xl:items-start">
|
||||
@@ -444,6 +631,17 @@ const Cutting = () => {
|
||||
}}
|
||||
/>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={openProductionOrderPreview}
|
||||
disabled={isGeneratingOrders || !rowsAvailableForOrder.length}
|
||||
className="flex items-center justify-center gap-2 rounded-xl border border-brand-primary/30 bg-brand-primary/15 px-4 py-2.5 text-sm font-medium text-brand-primary shadow-sm transition-colors hover:border-brand-primary disabled:cursor-not-allowed disabled:opacity-50 cursor-pointer"
|
||||
title="Revisar ordens locais antes de gerar"
|
||||
>
|
||||
<ClipboardList size={16} />
|
||||
<span className="hidden sm:inline">{isGeneratingOrders ? 'Gerando' : 'Prévia OPs'}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={exportRows}
|
||||
@@ -466,8 +664,136 @@ const Cutting = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{generationMessage && (
|
||||
<div className="rounded-xl border border-dark-border bg-dark-card px-4 py-3 text-sm font-bold text-dark-muted">
|
||||
{generationMessage}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<RefreshStatus isRefreshing={isRefreshing} />
|
||||
|
||||
{isOrderPreviewOpen && (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex h-dvh items-center justify-center overflow-hidden bg-zinc-950/80 p-4 backdrop-blur-md dark:bg-black/75"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Prévia de ordens de produção"
|
||||
onClick={() => {
|
||||
if (!isGeneratingOrders) setIsOrderPreviewOpen(false);
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="flex max-h-[90dvh] w-full max-w-6xl flex-col overflow-hidden rounded-2xl border border-dark-border bg-dark-card shadow-2xl"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="flex flex-col gap-4 border-b border-dark-border p-5 md:flex-row md:items-start md:justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-dark-text">Prévia das OPs</h2>
|
||||
<p className="mt-1 text-sm font-semibold text-dark-muted">
|
||||
Serão criadas ordens locais para as necessidades do filtro atual.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsOrderPreviewOpen(false)}
|
||||
disabled={isGeneratingOrders}
|
||||
className="inline-flex h-9 w-9 items-center justify-center rounded-xl border border-dark-border bg-dark-input text-dark-muted transition-colors hover:border-brand-primary hover:text-dark-text disabled:cursor-not-allowed disabled:opacity-50 cursor-pointer"
|
||||
title="Fechar"
|
||||
aria-label="Fechar prévia"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-3 border-b border-dark-border p-4 md:grid-cols-3">
|
||||
<div className="rounded-xl border border-dark-border bg-dark-input/60 p-3">
|
||||
<p className="text-[10px] font-bold uppercase tracking-widest text-dark-muted">OPs previstas</p>
|
||||
<p className="mt-2 text-2xl font-bold text-dark-text">{formatNumber(rowsAvailableForOrder.length)}</p>
|
||||
</div>
|
||||
<div className="rounded-xl border border-dark-border bg-dark-input/60 p-3">
|
||||
<p className="text-[10px] font-bold uppercase tracking-widest text-dark-muted">Unidades totais</p>
|
||||
<p className="mt-2 text-2xl font-bold text-red-300">{formatNumber(orderPreviewTotalUnits)} un.</p>
|
||||
</div>
|
||||
<div className="rounded-xl border border-dark-border bg-dark-input/60 p-3">
|
||||
<p className="text-[10px] font-bold uppercase tracking-widest text-dark-muted">Cobertura alvo</p>
|
||||
<p className="mt-2 text-2xl font-bold text-brand-primary">{formatNumber(targetCoverageDays)} dias</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-auto">
|
||||
<table className="w-full min-w-[980px] text-left text-sm">
|
||||
<thead className="border-b border-dark-border bg-dark-header text-dark-muted">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-[10px] font-bold uppercase tracking-wider">SKU</th>
|
||||
<th className="px-4 py-3 text-[10px] font-bold uppercase tracking-wider">Produto</th>
|
||||
<th className="px-4 py-3 text-[10px] font-bold uppercase tracking-wider">Família</th>
|
||||
<th className="px-4 py-3 text-[10px] font-bold uppercase tracking-wider">Variação</th>
|
||||
<th className="px-4 py-3 text-[10px] font-bold uppercase tracking-wider">Necessidade</th>
|
||||
<th className="px-4 py-3 text-[10px] font-bold uppercase tracking-wider">Dados</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-dark-border">
|
||||
{rowsAvailableForOrder.map(row => (
|
||||
<tr key={row.id} className="hover:bg-dark-input/50">
|
||||
<td className="px-4 py-3 font-mono text-[11px] text-dark-muted">#{row.id}</td>
|
||||
<td className="max-w-0 px-4 py-3">
|
||||
<div className="truncate font-bold text-dark-text" title={row.name}>{row.name}</div>
|
||||
<div className="mt-1 text-[10px] font-semibold text-dark-muted">
|
||||
Média: {formatNumber(row.dailySales, 2)} un./dia · Estoque: {formatNumber(row.stock)} un.
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`inline-flex whitespace-nowrap rounded-full border px-2.5 py-1 text-xs font-bold ${familyStyles[row.family.key]}`}>
|
||||
{row.family.materialLabel}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
<ProductColorBadge label={row.color} className="max-w-[110px] px-2" />
|
||||
<span className="inline-flex items-center gap-1 rounded-full border border-emerald-400/25 bg-emerald-400/10 px-2 py-1 text-xs font-bold text-emerald-300">
|
||||
<Ruler className="h-3 w-3" />
|
||||
{row.size || '-'}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 font-bold whitespace-nowrap text-red-300">
|
||||
{formatNumber(row.suggestedCutQuantity)} un.
|
||||
</td>
|
||||
<td className="px-4 py-3">{renderIssueBadge(row)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 border-t border-dark-border p-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<p className="text-xs font-semibold text-dark-muted">
|
||||
OPs abertas já existentes para o mesmo plano e SKU serão ignoradas.
|
||||
</p>
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsOrderPreviewOpen(false)}
|
||||
disabled={isGeneratingOrders}
|
||||
className="inline-flex items-center justify-center rounded-xl border border-dark-border bg-dark-input px-4 py-2 text-sm font-bold text-dark-text transition-colors hover:border-brand-primary disabled:cursor-not-allowed disabled:opacity-50 cursor-pointer"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void generateProductionOrders()}
|
||||
disabled={isGeneratingOrders}
|
||||
className="inline-flex items-center justify-center gap-2 rounded-xl border border-brand-primary/30 bg-brand-primary/15 px-4 py-2 text-sm font-bold text-brand-primary transition-colors hover:border-brand-primary disabled:cursor-not-allowed disabled:opacity-50 cursor-pointer"
|
||||
>
|
||||
<ClipboardList className="h-4 w-4" />
|
||||
{isGeneratingOrders ? 'Gerando OPs' : 'Confirmar geração'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isSettingsOpen && (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex h-dvh justify-end overflow-hidden bg-zinc-950/80 backdrop-blur-md dark:bg-black/75"
|
||||
@@ -699,8 +1025,8 @@ const Cutting = () => {
|
||||
<div className="rounded-2xl border border-dark-border bg-dark-card p-5 shadow-sm">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-xs font-bold uppercase tracking-widest text-dark-muted">Unidades a cortar</p>
|
||||
<p className="mt-2 text-3xl font-bold text-red-300">{formatNumber(cutPlan.summary.suggestedCutQuantity)}</p>
|
||||
<p className="text-xs font-bold uppercase tracking-widest text-dark-muted">Necessidade bruta</p>
|
||||
<p className="mt-2 text-3xl font-bold text-red-300">{formatNumber(cutExecutionSummary.rawUnits)}</p>
|
||||
<p className="mt-1 text-xs font-semibold text-dark-muted">{formatNumber(cutPlan.summary.skuCount)} SKUs com necessidade</p>
|
||||
</div>
|
||||
<div className="rounded-xl border border-red-400/25 bg-red-400/10 p-3 text-red-300">
|
||||
@@ -712,11 +1038,9 @@ const Cutting = () => {
|
||||
<div className="rounded-2xl border border-dark-border bg-dark-card p-5 shadow-sm">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-xs font-bold uppercase tracking-widest text-dark-muted">Famílias</p>
|
||||
<p className="mt-2 text-3xl font-bold text-dark-text">{formatNumber(cutPlan.summary.familiesWithNeed)}</p>
|
||||
<p className="mt-1 text-xs font-semibold text-dark-muted">
|
||||
{cutPlan.summary.estimatedRolls === null ? 'Rolos pendentes de rendimento' : `${formatNumber(cutPlan.summary.estimatedRolls)} rolos estimados`}
|
||||
</p>
|
||||
<p className="text-xs font-bold uppercase tracking-widest text-dark-muted">Pronto para corte</p>
|
||||
<p className="mt-2 text-3xl font-bold text-emerald-300">{formatNumber(cutExecutionSummary.readyUnits)}</p>
|
||||
<p className="mt-1 text-xs font-semibold text-dark-muted">{formatNumber(cutExecutionSummary.readyRows)} SKUs liberados para gerar OP</p>
|
||||
</div>
|
||||
<div className="rounded-xl border border-brand-primary/25 bg-brand-primary/10 p-3 text-brand-primary">
|
||||
<Layers3 className="h-5 w-5" />
|
||||
@@ -727,14 +1051,12 @@ const Cutting = () => {
|
||||
<div className="rounded-2xl border border-dark-border bg-dark-card p-5 shadow-sm">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-xs font-bold uppercase tracking-widest text-dark-muted">Cores / tamanhos</p>
|
||||
<p className="mt-2 text-3xl font-bold text-sky-300">
|
||||
{formatNumber(cutPlan.summary.colorsWithNeed)} / {formatNumber(cutPlan.summary.sizesWithNeed)}
|
||||
</p>
|
||||
<p className="mt-1 text-xs font-semibold text-dark-muted">Com corte sugerido</p>
|
||||
<p className="text-xs font-bold uppercase tracking-widest text-dark-muted">Bloqueios</p>
|
||||
<p className="mt-2 text-3xl font-bold text-amber-300">{formatNumber(cutExecutionSummary.blockedRows)}</p>
|
||||
<p className="mt-1 text-xs font-semibold text-dark-muted">{formatNumber(cutExecutionSummary.blockedByMaterial)} material · {formatNumber(cutExecutionSummary.blockedByData)} dados</p>
|
||||
</div>
|
||||
<div className="rounded-xl border border-sky-400/25 bg-sky-400/10 p-3 text-sky-300">
|
||||
<Palette className="h-5 w-5" />
|
||||
<div className="rounded-xl border border-amber-400/25 bg-amber-400/10 p-3 text-amber-300">
|
||||
<AlertTriangle className="h-5 w-5" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -742,18 +1064,80 @@ const Cutting = () => {
|
||||
<div className="rounded-2xl border border-dark-border bg-dark-card p-5 shadow-sm">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-xs font-bold uppercase tracking-widest text-dark-muted">OP aberta</p>
|
||||
<p className="mt-2 text-3xl font-bold text-amber-300">{formatNumber(cutPlan.summary.openProductionQuantity)}</p>
|
||||
<p className="mt-1 text-xs font-semibold text-dark-muted">Unidades abatidas quando há match</p>
|
||||
<p className="text-xs font-bold uppercase tracking-widest text-dark-muted">Famílias</p>
|
||||
<p className="mt-2 text-3xl font-bold text-brand-primary">{formatNumber(cutPlan.summary.familiesWithNeed)}</p>
|
||||
<p className="mt-1 text-xs font-semibold text-dark-muted">{cutPlan.summary.estimatedRolls === null ? 'Rendimento ainda não configurado' : `${formatNumber(cutPlan.summary.estimatedRolls)} rolos estimados`}</p>
|
||||
</div>
|
||||
<div className="rounded-xl border border-amber-400/25 bg-amber-400/10 p-3 text-amber-300">
|
||||
<ClipboardList className="h-5 w-5" />
|
||||
<div className="rounded-xl border border-brand-primary/25 bg-brand-primary/10 p-3 text-brand-primary">
|
||||
<Layers3 className="h-5 w-5" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!!issueSummaries.length && (
|
||||
{cutExecutionSummary.rawUnits > 0 && cutExecutionSummary.readyUnits === 0 && (
|
||||
<div className="flex flex-col gap-3 rounded-2xl border border-amber-400/30 bg-amber-400/10 p-4 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-bold text-amber-300">Há demanda, mas nenhum SKU está liberado para corte.</p>
|
||||
<p className="mt-1 text-xs font-semibold text-dark-muted">Corrija materiais bloqueados e rendimentos antes de gerar ordens de produção.</p>
|
||||
</div>
|
||||
<button type="button" onClick={() => setActiveCuttingView('issues')} className="h-9 rounded-lg border border-amber-400/30 bg-dark-card px-3 text-xs font-bold text-amber-300 hover:border-amber-400 cursor-pointer">Ver pendências</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="rounded-2xl border border-dark-border bg-dark-card p-2 shadow-sm">
|
||||
<div className="grid grid-cols-1 gap-2 md:grid-cols-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveCuttingView('plan')}
|
||||
aria-pressed={activeCuttingView === 'plan'}
|
||||
className={`flex items-center justify-between gap-3 rounded-xl border px-4 py-3 text-left transition-colors cursor-pointer ${activeCuttingView === 'plan' ? 'border-brand-primary/40 bg-brand-primary/15 text-brand-primary' : 'border-transparent text-dark-muted hover:bg-dark-input hover:text-dark-text'}`}
|
||||
>
|
||||
<span className="flex items-center gap-3">
|
||||
<Scissors className="h-4 w-4" />
|
||||
<span>
|
||||
<span className="block text-sm font-bold">Necessidades</span>
|
||||
<span className="block text-xs font-semibold opacity-80">Demanda por SKU</span>
|
||||
</span>
|
||||
</span>
|
||||
<span className="text-sm font-bold">{formatNumber(cutPlan.summary.skuCount)}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveCuttingView('families')}
|
||||
aria-pressed={activeCuttingView === 'families'}
|
||||
className={`flex items-center justify-between gap-3 rounded-xl border px-4 py-3 text-left transition-colors cursor-pointer ${activeCuttingView === 'families' ? 'border-brand-primary/40 bg-brand-primary/15 text-brand-primary' : 'border-transparent text-dark-muted hover:bg-dark-input hover:text-dark-text'}`}
|
||||
>
|
||||
<span className="flex items-center gap-3">
|
||||
<Layers3 className="h-4 w-4" />
|
||||
<span>
|
||||
<span className="block text-sm font-bold">Famílias</span>
|
||||
<span className="block text-xs font-semibold opacity-80">Resumo de corte</span>
|
||||
</span>
|
||||
</span>
|
||||
<span className="text-sm font-bold">{formatNumber(cutPlan.familySummaries.length)}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveCuttingView('issues')}
|
||||
aria-pressed={activeCuttingView === 'issues'}
|
||||
className={`flex items-center justify-between gap-3 rounded-xl border px-4 py-3 text-left transition-colors cursor-pointer ${activeCuttingView === 'issues' ? 'border-amber-400/40 bg-amber-400/10 text-amber-300' : 'border-transparent text-dark-muted hover:bg-dark-input hover:text-dark-text'}`}
|
||||
>
|
||||
<span className="flex items-center gap-3">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<span>
|
||||
<span className="block text-sm font-bold">Pendências</span>
|
||||
<span className="block text-xs font-semibold opacity-80">Dados a revisar</span>
|
||||
</span>
|
||||
</span>
|
||||
<span className="text-sm font-bold">{formatNumber(cutPlan.summary.rowsWithIssues)}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{activeCuttingView === 'issues' && (
|
||||
<div className="rounded-2xl border border-dark-border bg-dark-card p-4 shadow-sm">
|
||||
<div className="mb-3 flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
@@ -776,8 +1160,21 @@ const Cutting = () => {
|
||||
<Settings2 className="h-4 w-4 text-brand-primary" />
|
||||
Corrigir
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setCutFilter('issues');
|
||||
setCurrentPage(1);
|
||||
setActiveCuttingView('plan');
|
||||
}}
|
||||
className="inline-flex items-center justify-center gap-2 rounded-xl border border-dark-border bg-dark-input px-3 py-2 text-xs font-bold text-dark-text transition-colors hover:border-brand-primary cursor-pointer"
|
||||
>
|
||||
<Search className="h-4 w-4 text-brand-primary" />
|
||||
Ver SKUs
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{issueSummaries.length ? (
|
||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-4">
|
||||
{issueSummaries.map(summary => (
|
||||
<div
|
||||
@@ -792,10 +1189,16 @@ const Cutting = () => {
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-xl border border-dark-border bg-dark-input/60 px-4 py-8 text-center">
|
||||
<p className="text-sm font-bold text-dark-text">Nenhuma pendência no plano atual.</p>
|
||||
<p className="mt-1 text-xs font-semibold text-dark-muted">Os SKUs com necessidade já têm os dados mínimos para o cálculo.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!!cutPlan.familySummaries.length && (
|
||||
{activeCuttingView === 'families' && (
|
||||
<div className="rounded-2xl border border-dark-border bg-dark-card p-4 shadow-sm">
|
||||
<div className="mb-3 flex items-center justify-between gap-3">
|
||||
<div>
|
||||
@@ -804,6 +1207,7 @@ const Cutting = () => {
|
||||
</div>
|
||||
<Scissors className="h-5 w-5 text-dark-muted" />
|
||||
</div>
|
||||
{cutPlan.familySummaries.length ? (
|
||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 2xl:grid-cols-5">
|
||||
{cutPlan.familySummaries.map(summary => (
|
||||
<div
|
||||
@@ -826,9 +1230,17 @@ const Cutting = () => {
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-xl border border-dark-border bg-dark-input/60 px-4 py-8 text-center">
|
||||
<p className="text-sm font-bold text-dark-text">Nenhuma família com necessidade no período.</p>
|
||||
<p className="mt-1 text-xs font-semibold text-dark-muted">Ajuste o período ou a cobertura alvo para revisar outro cenário.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeCuttingView === 'plan' && (
|
||||
<>
|
||||
<div className="grid grid-cols-1 gap-3 rounded-2xl border border-dark-border bg-dark-card p-4 shadow-sm xl:grid-cols-[1fr_170px_170px_170px_190px]">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 h-5 w-5 -translate-y-1/2 text-zinc-400 dark:text-dark-muted" />
|
||||
@@ -901,7 +1313,7 @@ const Cutting = () => {
|
||||
) : (
|
||||
<div className={`overflow-hidden rounded-2xl border border-zinc-200 bg-white shadow-sm dark:border-dark-border dark:bg-dark-card ${isRefreshing ? 'refreshing-content' : ''}`} aria-busy={isRefreshing}>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[1180px] table-fixed text-left text-sm">
|
||||
<table className="w-full min-w-[1450px] table-fixed text-left text-sm">
|
||||
<colgroup>
|
||||
<col className="w-[105px]" />
|
||||
<col className="w-[320px]" />
|
||||
@@ -911,8 +1323,9 @@ const Cutting = () => {
|
||||
<col className="w-[125px]" />
|
||||
<col className="w-[140px]" />
|
||||
<col className="w-[80px]" />
|
||||
<col className="w-[130px]" />
|
||||
<col className="w-[110px]" />
|
||||
<col className="w-[230px]" />
|
||||
<col className="w-[160px]" />
|
||||
<col className="w-[100px]" />
|
||||
</colgroup>
|
||||
<thead className="border-b border-zinc-100 bg-zinc-50 text-zinc-500 dark:border-dark-border dark:bg-dark-header dark:text-dark-muted">
|
||||
<tr>
|
||||
@@ -924,6 +1337,7 @@ const Cutting = () => {
|
||||
<th className="px-4 py-4 text-[10px] font-bold uppercase tracking-wider">Estoque</th>
|
||||
<th className="px-4 py-4 text-[10px] font-bold uppercase tracking-wider">Necessidade</th>
|
||||
<th className="px-4 py-4 text-[10px] font-bold uppercase tracking-wider">Rolos</th>
|
||||
<th className="px-4 py-4 text-[10px] font-bold uppercase tracking-wider">Materiais</th>
|
||||
<th
|
||||
className="px-4 py-4 text-[10px] font-bold uppercase tracking-wider"
|
||||
title="Dados que ainda faltam para fechar o plano de corte do SKU."
|
||||
@@ -972,7 +1386,8 @@ const Cutting = () => {
|
||||
<td className="px-4 py-2.5 font-bold whitespace-nowrap text-zinc-900 dark:text-dark-text">
|
||||
{row.estimatedRolls === null ? '-' : formatNumber(row.estimatedRolls)}
|
||||
</td>
|
||||
<td className="px-4 py-2.5">{renderIssueBadge(row)}</td>
|
||||
<td className="px-4 py-2.5 align-middle">{renderMaterialReadiness(row)}</td>
|
||||
<td className="px-4 py-2.5 align-middle">{renderIssueBadge(row)}</td>
|
||||
<td className="px-4 py-2.5 text-right">
|
||||
<div className="flex justify-end gap-2">
|
||||
<Link
|
||||
@@ -1025,6 +1440,8 @@ const Cutting = () => {
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,29 +1,187 @@
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Lock } from 'lucide-react';
|
||||
import { login } from '../dataService';
|
||||
import { getLoginConfig, login } from '../dataService';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
turnstile?: {
|
||||
render: (
|
||||
container: HTMLElement,
|
||||
options: {
|
||||
sitekey: string;
|
||||
callback: (token: string) => void;
|
||||
'expired-callback': () => void;
|
||||
'error-callback': () => void;
|
||||
theme: 'dark' | 'light' | 'auto';
|
||||
}
|
||||
) => string;
|
||||
reset: (widgetId?: string) => void;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const turnstileScriptId = 'turnstile-api-script';
|
||||
const securityConfigLoadMessage = 'Não foi possível carregar as configurações de segurança. Atualize a página e tente novamente.';
|
||||
const securityUnavailableMessage = 'Não foi possível carregar a verificação de segurança. Atualize a página e tente novamente.';
|
||||
const securityConfigurationMessage = 'Verificação de segurança indisponível. Entre em contato com o administrador.';
|
||||
const securityRequiredMessage = 'Conclua a verificação de segurança para continuar.';
|
||||
const securityFailedMessage = 'Não foi possível validar a verificação de segurança. Atualize a página e tente novamente.';
|
||||
const turnstileSiteKeyPattern = /^[0-9]x[0-9A-Za-z_-]{20,}$/;
|
||||
|
||||
const Login = () => {
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [captchaToken, setCaptchaToken] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isConfigLoading, setIsConfigLoading] = useState(true);
|
||||
const [turnstileSiteKey, setTurnstileSiteKey] = useState('');
|
||||
const [captchaRequired, setCaptchaRequired] = useState(false);
|
||||
const [captchaReady, setCaptchaReady] = useState(true);
|
||||
const captchaContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
const captchaWidgetIdRef = useRef<string | null>(null);
|
||||
const navigate = useNavigate();
|
||||
const hasValidSiteKey = turnstileSiteKeyPattern.test(turnstileSiteKey);
|
||||
const captchaEnabled = Boolean(captchaRequired && hasValidSiteKey);
|
||||
const securityMisconfigured = captchaRequired && !hasValidSiteKey;
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
|
||||
const loadLoginConfig = async () => {
|
||||
try {
|
||||
const config = await getLoginConfig();
|
||||
if (!isMounted) return;
|
||||
|
||||
setCaptchaRequired(config.captchaRequired);
|
||||
setTurnstileSiteKey(config.turnstileSiteKey.trim());
|
||||
setCaptchaReady(!config.captchaRequired);
|
||||
if (config.captchaRequired && !config.captchaConfigured) {
|
||||
setError(securityConfigurationMessage);
|
||||
}
|
||||
} catch {
|
||||
if (!isMounted) return;
|
||||
setCaptchaReady(false);
|
||||
setError(securityConfigLoadMessage);
|
||||
} finally {
|
||||
if (isMounted) {
|
||||
setIsConfigLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
void loadLoginConfig();
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasValidSiteKey || !captchaContainerRef.current || captchaWidgetIdRef.current) return;
|
||||
|
||||
const siteKey = turnstileSiteKey;
|
||||
|
||||
const renderCaptcha = () => {
|
||||
if (!window.turnstile || !captchaContainerRef.current || captchaWidgetIdRef.current) return;
|
||||
|
||||
try {
|
||||
captchaWidgetIdRef.current = window.turnstile.render(captchaContainerRef.current, {
|
||||
sitekey: siteKey,
|
||||
theme: 'dark',
|
||||
callback: (token) => {
|
||||
setCaptchaToken(token);
|
||||
setCaptchaReady(true);
|
||||
},
|
||||
'expired-callback': () => {
|
||||
setCaptchaToken('');
|
||||
setCaptchaReady(true);
|
||||
},
|
||||
'error-callback': () => {
|
||||
setCaptchaToken('');
|
||||
setCaptchaReady(false);
|
||||
setError(securityUnavailableMessage);
|
||||
},
|
||||
});
|
||||
setCaptchaReady(true);
|
||||
} catch {
|
||||
setCaptchaToken('');
|
||||
setCaptchaReady(false);
|
||||
setError(securityConfigurationMessage);
|
||||
}
|
||||
};
|
||||
|
||||
if (window.turnstile) {
|
||||
renderCaptcha();
|
||||
return;
|
||||
}
|
||||
|
||||
const existingScript = document.getElementById(turnstileScriptId);
|
||||
if (existingScript) {
|
||||
existingScript.addEventListener('load', renderCaptcha, { once: true });
|
||||
return () => existingScript.removeEventListener('load', renderCaptcha);
|
||||
}
|
||||
|
||||
const script = document.createElement('script');
|
||||
script.id = turnstileScriptId;
|
||||
script.src = 'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit';
|
||||
script.async = true;
|
||||
script.defer = true;
|
||||
script.addEventListener('load', renderCaptcha, { once: true });
|
||||
script.addEventListener('error', () => {
|
||||
setCaptchaReady(false);
|
||||
setError(securityUnavailableMessage);
|
||||
}, { once: true });
|
||||
document.head.appendChild(script);
|
||||
|
||||
return () => script.removeEventListener('load', renderCaptcha);
|
||||
}, [hasValidSiteKey, turnstileSiteKey]);
|
||||
|
||||
const handleLogin = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (securityMisconfigured) {
|
||||
setError(securityConfigurationMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
if (captchaEnabled && !captchaToken) {
|
||||
setError(securityRequiredMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const success = await login(email, password);
|
||||
if (success) {
|
||||
const result = await login(email, password, captchaToken);
|
||||
if (result === 'success') {
|
||||
navigate('/graph');
|
||||
} else {
|
||||
} else if (result === 'captcha_failed') {
|
||||
setError(captchaEnabled ? securityFailedMessage : securityConfigurationMessage);
|
||||
if (captchaEnabled) {
|
||||
setCaptchaToken('');
|
||||
window.turnstile?.reset(captchaWidgetIdRef.current ?? undefined);
|
||||
}
|
||||
} else if (result === 'invalid_credentials') {
|
||||
setError('E-mail ou senha incorretos.');
|
||||
if (captchaEnabled) {
|
||||
setCaptchaToken('');
|
||||
window.turnstile?.reset(captchaWidgetIdRef.current ?? undefined);
|
||||
}
|
||||
} else {
|
||||
setError('Erro ao conectar ao servidor.');
|
||||
if (captchaEnabled) {
|
||||
setCaptchaToken('');
|
||||
window.turnstile?.reset(captchaWidgetIdRef.current ?? undefined);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
setError('Erro ao conectar ao servidor.');
|
||||
if (captchaEnabled) {
|
||||
setCaptchaToken('');
|
||||
window.turnstile?.reset(captchaWidgetIdRef.current ?? undefined);
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
@@ -64,14 +222,23 @@ const Login = () => {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-red-500 text-sm font-medium">{error}</p>}
|
||||
{(captchaEnabled || securityMisconfigured) && (
|
||||
<div className="min-h-[70px] rounded-xl border border-dark-border bg-dark-input/60 p-3">
|
||||
{captchaEnabled && <div ref={captchaContainerRef} className="flex justify-center" />}
|
||||
{(securityMisconfigured || !captchaReady) && (
|
||||
<p className="mt-2 text-center text-xs font-medium text-red-400">Verificação de segurança indisponível.</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && <p className="text-red-500 text-sm font-medium" role="alert">{error}</p>}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
disabled={isLoading || isConfigLoading || securityMisconfigured || !captchaReady || (captchaEnabled && !captchaToken)}
|
||||
className="w-full bg-brand-primary hover:bg-opacity-90 hover:scale-[1.02] active:scale-[0.98] text-zinc-900 font-bold py-3 rounded-xl transition-all duration-200 disabled:opacity-50 disabled:hover:scale-100 disabled:active:scale-100 mt-4 cursor-pointer"
|
||||
>
|
||||
{isLoading ? 'Entrando...' : 'Entrar'}
|
||||
{isLoading || isConfigLoading ? 'Entrando...' : 'Entrar'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
439
src/pages/PlanningIssues.tsx
Normal file
439
src/pages/PlanningIssues.tsx
Normal file
@@ -0,0 +1,439 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Link, useOutletContext } from 'react-router-dom';
|
||||
import { AlertTriangle, CheckCircle2, Eye, PackageSearch, Pencil, Search } from 'lucide-react';
|
||||
import DateRangePicker from '../components/DateRangePicker';
|
||||
import PaginationControls from '../components/PaginationControls';
|
||||
import ProductTypeBadge from '../components/ProductTypeBadge';
|
||||
import RefreshStatus from '../components/RefreshStatus';
|
||||
import SkuPlanningModal from '../components/SkuPlanningModal';
|
||||
import { classifyCutFamily, CUT_FAMILY_RULES, type CutFamilyKey } from '../analytics/cutting';
|
||||
import { fetchCuttingSettings, fetchProductAnalytics, saveCuttingSettings } from '../dataService';
|
||||
import { getProductTypeConfig, resolveProductType, type ProductTypeKey } from '../productClassification';
|
||||
import { parseProductName } from '../productParsing';
|
||||
import type { CutProductOverride, CuttingSettings, DateRange, ProductAnalyticsItem } from '../types';
|
||||
|
||||
type IssueKey = 'review_type' | 'missing_cut_family' | 'missing_color' | 'missing_size' | 'missing_yield';
|
||||
type IssueFilter = 'all' | IssueKey;
|
||||
|
||||
type PlanningIssueRow = ProductAnalyticsItem & {
|
||||
productType: ProductTypeKey;
|
||||
color: string;
|
||||
size: string;
|
||||
familyKey: CutFamilyKey | '';
|
||||
familyLabel: string;
|
||||
issues: IssueKey[];
|
||||
priorityScore: number;
|
||||
hasOverride: boolean;
|
||||
};
|
||||
|
||||
const issueLabels: Record<IssueKey, string> = {
|
||||
review_type: 'Tipo',
|
||||
missing_cut_family: 'Família',
|
||||
missing_color: 'Cor',
|
||||
missing_size: 'Tamanho',
|
||||
missing_yield: 'Rendimento'
|
||||
};
|
||||
|
||||
const issueHelp: Record<IssueKey, string> = {
|
||||
review_type: 'O classificador não conseguiu identificar o tipo do SKU.',
|
||||
missing_cut_family: 'SKU de vestuário não caiu em uma família de corte confiável.',
|
||||
missing_color: 'SKU de vestuário não tem cor clara para planejamento.',
|
||||
missing_size: 'SKU de vestuário não tem tamanho claro para planejamento.',
|
||||
missing_yield: 'A família existe, mas ainda falta rendimento em unidades por rolo.'
|
||||
};
|
||||
|
||||
const issueFilters: Array<{ value: IssueFilter; label: string }> = [
|
||||
{ value: 'all', label: 'Todos' },
|
||||
{ value: 'review_type', label: issueLabels.review_type },
|
||||
{ value: 'missing_cut_family', label: issueLabels.missing_cut_family },
|
||||
{ value: 'missing_color', label: issueLabels.missing_color },
|
||||
{ value: 'missing_size', label: issueLabels.missing_size },
|
||||
{ value: 'missing_yield', label: issueLabels.missing_yield }
|
||||
];
|
||||
|
||||
const familyLabels = new Map<CutFamilyKey, string>([
|
||||
...CUT_FAMILY_RULES.map(rule => [rule.key, `${rule.materialLabel} · ${rule.label}`] as const),
|
||||
['OUTROS', 'Sem regra']
|
||||
]);
|
||||
|
||||
const formatNumber = (value: number, maximumFractionDigits = 0) => (
|
||||
new Intl.NumberFormat('pt-BR', { maximumFractionDigits }).format(value)
|
||||
);
|
||||
|
||||
const formatCurrency = (value: number) => (
|
||||
new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(value)
|
||||
);
|
||||
|
||||
const buildIssues = (
|
||||
product: ProductAnalyticsItem,
|
||||
settings: CuttingSettings
|
||||
): PlanningIssueRow => {
|
||||
const override = settings.productOverrides[product.id];
|
||||
const metadata = parseProductName(product.name);
|
||||
const productType = resolveProductType(product.name, override);
|
||||
const familyKey = override?.familyKey || classifyCutFamily(metadata.baseName).key;
|
||||
const color = override?.color || metadata.color;
|
||||
const size = (override?.size || metadata.size).toUpperCase();
|
||||
const issues: IssueKey[] = [];
|
||||
|
||||
if (productType === 'unknown') issues.push('review_type');
|
||||
|
||||
if (productType === 'finished_apparel') {
|
||||
if (familyKey === 'OUTROS') issues.push('missing_cut_family');
|
||||
if (!color) issues.push('missing_color');
|
||||
if (!size) issues.push('missing_size');
|
||||
if (familyKey !== 'OUTROS' && !settings.familyYields[familyKey]) issues.push('missing_yield');
|
||||
}
|
||||
|
||||
return {
|
||||
...product,
|
||||
productType,
|
||||
color,
|
||||
size,
|
||||
familyKey,
|
||||
familyLabel: familyLabels.get(familyKey) || '-',
|
||||
issues,
|
||||
priorityScore: (product.quantitySold * 2) + (product.revenue / 100),
|
||||
hasOverride: Boolean(override)
|
||||
};
|
||||
};
|
||||
|
||||
const PlanningIssuesSkeleton = () => (
|
||||
<div className="overflow-hidden rounded-2xl border border-dark-border bg-dark-card shadow-sm" aria-label="Carregando dados pendentes">
|
||||
<div className="border-b border-dark-border p-4">
|
||||
<div className="grid grid-cols-[120px_1.4fr_150px_170px_130px_130px_120px] gap-5">
|
||||
{[0, 1, 2, 3, 4, 5, 6].map(item => <div key={item} className="skeleton h-3" />)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="divide-y divide-dark-border">
|
||||
{[0, 1, 2, 3, 4, 5].map(row => (
|
||||
<div key={row} className="grid grid-cols-[120px_1.4fr_150px_170px_130px_130px_120px] gap-5 px-6 py-4">
|
||||
{[0, 1, 2, 3, 4, 5, 6].map(item => <div key={item} className="skeleton h-4" />)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const PlanningIssues = () => {
|
||||
const { dateRange, setDateRange } = useOutletContext<{
|
||||
dateRange: DateRange,
|
||||
setDateRange: (range: DateRange) => void
|
||||
}>();
|
||||
const [products, setProducts] = useState<ProductAnalyticsItem[]>([]);
|
||||
const [settings, setSettings] = useState<CuttingSettings>({ familyYields: {}, productOverrides: {} });
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [issueFilter, setIssueFilter] = useState<IssueFilter>('all');
|
||||
const [editingProduct, setEditingProduct] = useState<PlanningIssueRow | null>(null);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [itemsPerPage, setItemsPerPage] = useState(20);
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
|
||||
const loadData = async () => {
|
||||
setIsLoading(true);
|
||||
const [productData, planningSettings] = await Promise.all([
|
||||
fetchProductAnalytics(dateRange),
|
||||
fetchCuttingSettings()
|
||||
]);
|
||||
|
||||
if (isMounted) {
|
||||
setProducts(productData);
|
||||
setSettings(planningSettings);
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
void loadData();
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, [dateRange]);
|
||||
|
||||
const rows = useMemo(() => {
|
||||
const normalizedSearch = searchTerm.trim().toLowerCase();
|
||||
|
||||
return products
|
||||
.map(product => buildIssues(product, settings))
|
||||
.filter(row => row.issues.length > 0)
|
||||
.filter(row => issueFilter === 'all' || row.issues.includes(issueFilter))
|
||||
.filter(row => {
|
||||
if (!normalizedSearch) return true;
|
||||
const typeLabel = getProductTypeConfig(row.productType).label.toLowerCase();
|
||||
return (
|
||||
row.id.toLowerCase().includes(normalizedSearch) ||
|
||||
row.name.toLowerCase().includes(normalizedSearch) ||
|
||||
row.color.toLowerCase().includes(normalizedSearch) ||
|
||||
row.size.toLowerCase().includes(normalizedSearch) ||
|
||||
row.familyLabel.toLowerCase().includes(normalizedSearch) ||
|
||||
typeLabel.includes(normalizedSearch)
|
||||
);
|
||||
})
|
||||
.sort((a, b) => {
|
||||
if (b.issues.length !== a.issues.length) return b.issues.length - a.issues.length;
|
||||
return b.priorityScore - a.priorityScore;
|
||||
});
|
||||
}, [issueFilter, products, searchTerm, settings]);
|
||||
|
||||
const issueCounts = useMemo(() => {
|
||||
const counts = new Map<IssueKey, number>();
|
||||
products
|
||||
.map(product => buildIssues(product, settings))
|
||||
.forEach(row => row.issues.forEach(issue => counts.set(issue, (counts.get(issue) || 0) + 1)));
|
||||
return counts;
|
||||
}, [products, settings]);
|
||||
|
||||
const saveProductOverride = async (productId: string, override: CutProductOverride | null) => {
|
||||
const productOverrides = { ...settings.productOverrides };
|
||||
if (override) {
|
||||
productOverrides[productId] = override;
|
||||
} else {
|
||||
delete productOverrides[productId];
|
||||
}
|
||||
|
||||
const nextSettings = { ...settings, productOverrides };
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const savedSettings = await saveCuttingSettings(nextSettings);
|
||||
setSettings(savedSettings);
|
||||
setEditingProduct(null);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const totalPages = Math.ceil(rows.length / itemsPerPage);
|
||||
const safeCurrentPage = Math.min(currentPage, totalPages || 1);
|
||||
const startIndex = (safeCurrentPage - 1) * itemsPerPage;
|
||||
const paginatedRows = rows.slice(startIndex, startIndex + itemsPerPage);
|
||||
const isRefreshing = isLoading && products.length > 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="grid grid-cols-1 gap-4 2xl:grid-cols-[minmax(520px,1fr)_auto] 2xl:items-start">
|
||||
<div>
|
||||
<h1 className="mb-2 text-2xl font-bold text-zinc-900 dark:text-dark-text">Dados Pendentes</h1>
|
||||
<p className="font-medium text-zinc-500 dark:text-dark-muted">
|
||||
Fila principal para revisar tipo, cor, tamanho, família e rendimento dos SKUs.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center 2xl:justify-end">
|
||||
<Link
|
||||
to="/registrations"
|
||||
className="inline-flex h-10 items-center justify-center gap-2 rounded-xl border border-dark-border bg-dark-card px-4 text-sm font-bold text-dark-text transition-colors hover:border-brand-primary"
|
||||
>
|
||||
<PackageSearch className="h-4 w-4 text-brand-primary" />
|
||||
Cadastros
|
||||
</Link>
|
||||
<DateRangePicker
|
||||
dateRange={dateRange}
|
||||
onChange={(range) => {
|
||||
setDateRange(range);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<RefreshStatus isRefreshing={isRefreshing} />
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-3 xl:grid-cols-5">
|
||||
{issueFilters.filter(option => option.value !== 'all').map(option => {
|
||||
const count = issueCounts.get(option.value as IssueKey) || 0;
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setIssueFilter(option.value);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
className={`rounded-2xl border p-4 text-left transition-colors cursor-pointer ${
|
||||
issueFilter === option.value
|
||||
? 'border-brand-primary/45 bg-brand-primary/10'
|
||||
: 'border-dark-border bg-dark-card hover:border-brand-primary/35'
|
||||
}`}
|
||||
title={issueHelp[option.value as IssueKey]}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="text-xs font-bold uppercase tracking-widest text-dark-muted">{option.label}</span>
|
||||
<AlertTriangle className={count ? 'h-4 w-4 text-amber-300' : 'h-4 w-4 text-emerald-300'} />
|
||||
</div>
|
||||
<div className="mt-3 text-2xl font-bold text-dark-text">{formatNumber(count)}</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-3 rounded-2xl border border-dark-border bg-dark-card p-4 shadow-sm xl:grid-cols-[minmax(360px,1fr)_auto] xl:items-center">
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-center">
|
||||
<div className="relative w-full md:w-96">
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-dark-muted" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchTerm}
|
||||
onChange={(event) => {
|
||||
setSearchTerm(event.target.value);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
placeholder="Buscar SKU, produto, cor, tipo..."
|
||||
className="h-10 w-full rounded-xl border border-dark-border bg-dark-input pl-10 pr-3 text-sm font-semibold text-dark-text outline-none transition-colors placeholder:text-dark-muted focus:border-brand-primary"
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
value={issueFilter}
|
||||
onChange={(event) => {
|
||||
setIssueFilter(event.target.value as IssueFilter);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
className="h-10 rounded-xl border border-dark-border bg-dark-input px-3 text-sm font-bold text-dark-text outline-none transition-colors focus:border-brand-primary cursor-pointer"
|
||||
>
|
||||
{issueFilters.map(option => (
|
||||
<option key={option.value} value={option.value}>{option.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="text-sm font-semibold text-dark-muted">
|
||||
{formatNumber(rows.length)} SKUs pendentes
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoading && !products.length ? (
|
||||
<PlanningIssuesSkeleton />
|
||||
) : rows.length ? (
|
||||
<div className="overflow-hidden rounded-2xl border border-dark-border bg-dark-card shadow-sm">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[1180px] table-fixed text-left text-sm">
|
||||
<colgroup>
|
||||
<col className="w-[120px]" />
|
||||
<col className="w-[380px]" />
|
||||
<col className="w-[150px]" />
|
||||
<col className="w-[180px]" />
|
||||
<col className="w-[140px]" />
|
||||
<col className="w-[140px]" />
|
||||
<col className="w-[120px]" />
|
||||
<col className="w-[120px]" />
|
||||
</colgroup>
|
||||
<thead className="border-b border-dark-border bg-dark-header text-dark-muted">
|
||||
<tr>
|
||||
<th className="px-6 py-4 text-[10px] font-bold uppercase tracking-wider">SKU</th>
|
||||
<th className="px-6 py-4 text-[10px] font-bold uppercase tracking-wider">Produto</th>
|
||||
<th className="px-6 py-4 text-[10px] font-bold uppercase tracking-wider">Tipo</th>
|
||||
<th className="px-6 py-4 text-[10px] font-bold uppercase tracking-wider">Pendências</th>
|
||||
<th className="px-6 py-4 text-[10px] font-bold uppercase tracking-wider">Família</th>
|
||||
<th className="px-6 py-4 text-[10px] font-bold uppercase tracking-wider">Vendido</th>
|
||||
<th className="px-6 py-4 text-[10px] font-bold uppercase tracking-wider">Receita</th>
|
||||
<th className="px-4 py-4 text-right text-[10px] font-bold uppercase tracking-wider">Ações</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-dark-border">
|
||||
{paginatedRows.map(row => (
|
||||
<tr key={row.id} className="transition-colors hover:bg-dark-input/50">
|
||||
<td className="px-6 py-2.5 font-mono text-[11px] text-dark-muted">#{row.id}</td>
|
||||
<td className="max-w-0 px-6 py-2.5">
|
||||
<div className="truncate font-semibold text-dark-text" title={row.name}>{row.name}</div>
|
||||
<div className="mt-1 flex min-w-0 items-center gap-2 text-[10px] font-semibold text-dark-muted">
|
||||
<span className="truncate">Cor: {row.color || '-'}</span>
|
||||
<span>·</span>
|
||||
<span className="truncate">Tam.: {row.size || '-'}</span>
|
||||
{row.hasOverride && <span className="rounded-full border border-brand-primary/25 bg-brand-primary/10 px-2 py-0.5 text-brand-primary">manual</span>}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-2.5">
|
||||
<ProductTypeBadge type={row.productType} />
|
||||
</td>
|
||||
<td className="px-6 py-2.5">
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{row.issues.map(issue => (
|
||||
<span
|
||||
key={issue}
|
||||
className="inline-flex rounded-full border border-amber-400/25 bg-amber-400/10 px-2 py-0.5 text-[10px] font-bold text-amber-300"
|
||||
title={issueHelp[issue]}
|
||||
>
|
||||
{issueLabels[issue]}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-2.5 text-xs font-bold text-dark-text">{row.familyLabel}</td>
|
||||
<td className="px-6 py-2.5 whitespace-nowrap font-bold text-dark-text">{formatNumber(row.quantitySold)} un.</td>
|
||||
<td className="px-6 py-2.5 whitespace-nowrap font-bold text-brand-primary">{formatCurrency(row.revenue)}</td>
|
||||
<td className="px-4 py-2.5 text-right">
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditingProduct(row)}
|
||||
className="inline-flex h-8 w-8 items-center justify-center rounded-lg bg-dark-input text-dark-text transition-colors hover:bg-dark-border cursor-pointer"
|
||||
title={`Editar planejamento do SKU ${row.id}`}
|
||||
aria-label={`Editar planejamento do SKU ${row.id}`}
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<Link
|
||||
to={`/products/${row.id}`}
|
||||
className="inline-flex h-8 w-8 items-center justify-center rounded-lg bg-brand-primary/10 text-brand-primary transition-opacity hover:opacity-80 cursor-pointer"
|
||||
title={`Ver SKU ${row.id}`}
|
||||
aria-label={`Ver SKU ${row.id}`}
|
||||
>
|
||||
<Eye className="h-3.5 w-3.5" />
|
||||
</Link>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<PaginationControls
|
||||
totalItems={rows.length}
|
||||
currentPage={safeCurrentPage}
|
||||
totalPages={totalPages}
|
||||
pageSize={itemsPerPage}
|
||||
pageSizeOptions={[10, 20, 50, 100]}
|
||||
itemLabel="SKUs"
|
||||
pageSizeLabel="por página"
|
||||
startIndex={startIndex}
|
||||
endIndex={Math.min(startIndex + itemsPerPage, rows.length)}
|
||||
onPageChange={setCurrentPage}
|
||||
onPageSizeChange={(size) => {
|
||||
setItemsPerPage(size);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center rounded-2xl border border-dark-border bg-dark-card px-6 py-16 text-center shadow-sm">
|
||||
{products.length ? (
|
||||
<>
|
||||
<CheckCircle2 className="h-10 w-10 text-emerald-300" />
|
||||
<p className="mt-4 text-sm font-bold text-dark-text">Nenhum dado pendente neste filtro.</p>
|
||||
<p className="mt-1 text-sm font-semibold text-dark-muted">Ajuste o filtro ou o período para revisar outros SKUs.</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<PackageSearch className="h-10 w-10 text-dark-muted" />
|
||||
<p className="mt-4 text-sm font-bold text-dark-text">Sem produtos no período.</p>
|
||||
<p className="mt-1 text-sm font-semibold text-dark-muted">Altere o intervalo de datas para carregar a fila.</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{editingProduct && (
|
||||
<SkuPlanningModal
|
||||
product={editingProduct}
|
||||
override={settings.productOverrides[editingProduct.id]}
|
||||
isSaving={isSaving}
|
||||
onClose={() => setEditingProduct(null)}
|
||||
onSave={(override) => saveProductOverride(editingProduct.id, override)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PlanningIssues;
|
||||
@@ -1,15 +1,20 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useParams, Link, useOutletContext } from 'react-router-dom';
|
||||
import { Package, DollarSign, ReceiptText, Warehouse } from 'lucide-react';
|
||||
import { Package, DollarSign, Pencil, ReceiptText, Warehouse } from 'lucide-react';
|
||||
import { AreaChart, Area, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
|
||||
import BackButton from '../components/BackButton';
|
||||
import DateRangePicker from '../components/DateRangePicker';
|
||||
import SkuPlanningModal from '../components/SkuPlanningModal';
|
||||
import ProductTypeBadge from '../components/ProductTypeBadge';
|
||||
import RefreshStatus from '../components/RefreshStatus';
|
||||
import type { DateRange, ProductDetailsAnalytics } from '../types';
|
||||
import { fetchProductDetailsAnalytics } from '../dataService';
|
||||
import PaginationControls from '../components/PaginationControls';
|
||||
import type { CutProductOverride, CuttingSettings, DateRange, ProductComposition, ProductDetailsAnalytics } from '../types';
|
||||
import { fetchCuttingSettings, fetchProductComposition, fetchProductDetailsAnalytics, saveCuttingSettings } from '../dataService';
|
||||
import { parseProductName } from '../productParsing';
|
||||
import { formatColorLabel } from '../displayFormatters';
|
||||
import { averageRecentValues, formatDateBucketLabel, formatDateBucketLongLabel, getAutoDateBucket, getMovingAverageWindow, getRangeDayCount, getDateBucketKey, type DateBucket } from '../chartUtils';
|
||||
import { getProductTypeConfig, resolveProductType } from '../productClassification';
|
||||
import { getPlanningStock } from '../planningStock';
|
||||
|
||||
const CHART_GRID_COLOR = 'var(--chart-grid)';
|
||||
const CHART_AXIS_COLOR = 'var(--chart-axis)';
|
||||
@@ -127,9 +132,40 @@ const ProductDetails = () => {
|
||||
setDateRange: (range: DateRange) => void
|
||||
}>();
|
||||
const [details, setDetails] = useState<ProductDetailsAnalytics | null>(null);
|
||||
const [composition, setComposition] = useState<ProductComposition | null>(null);
|
||||
const [isCompositionLoading, setIsCompositionLoading] = useState(true);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [chartMetric, setChartMetric] = useState<ProductChartMetric>('quantity');
|
||||
const [selectedProductBucket, setSelectedProductBucket] = useState<string | null>(null);
|
||||
const [planningSettings, setPlanningSettings] = useState<CuttingSettings>({ familyYields: {}, productOverrides: {} });
|
||||
const [isPlanningModalOpen, setIsPlanningModalOpen] = useState(false);
|
||||
const [isSavingPlanning, setIsSavingPlanning] = useState(false);
|
||||
const [variantCurrentPage, setVariantCurrentPage] = useState(1);
|
||||
const [variantItemsPerPage, setVariantItemsPerPage] = useState(5);
|
||||
|
||||
useEffect(() => {
|
||||
const scrollContainer = document.querySelector<HTMLElement>('.app-content');
|
||||
if (scrollContainer) {
|
||||
scrollContainer.scrollTo({ top: 0, behavior: 'auto' });
|
||||
} else {
|
||||
window.scrollTo({ top: 0, behavior: 'auto' });
|
||||
}
|
||||
}, [id]);
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
|
||||
const loadPlanningSettings = async () => {
|
||||
const settings = await fetchCuttingSettings();
|
||||
if (isMounted) setPlanningSettings(settings);
|
||||
};
|
||||
|
||||
void loadPlanningSettings();
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
@@ -138,17 +174,26 @@ const ProductDetails = () => {
|
||||
if (!id) {
|
||||
if (isMounted) {
|
||||
setDetails(null);
|
||||
setComposition(null);
|
||||
setIsLoading(false);
|
||||
setIsCompositionLoading(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
const productDetails = await fetchProductDetailsAnalytics(id, dateRange);
|
||||
setIsCompositionLoading(true);
|
||||
const [productDetails, productComposition] = await Promise.all([
|
||||
fetchProductDetailsAnalytics(id, dateRange),
|
||||
fetchProductComposition(id)
|
||||
]);
|
||||
|
||||
if (isMounted) {
|
||||
setDetails(productDetails);
|
||||
setComposition(productComposition);
|
||||
setVariantCurrentPage(1);
|
||||
setIsLoading(false);
|
||||
setIsCompositionLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -167,6 +212,25 @@ const ProductDetails = () => {
|
||||
return new Intl.NumberFormat('pt-BR').format(value);
|
||||
};
|
||||
|
||||
const saveProductOverride = async (productId: string, override: CutProductOverride | null) => {
|
||||
const productOverrides = { ...planningSettings.productOverrides };
|
||||
if (override) {
|
||||
productOverrides[productId] = override;
|
||||
} else {
|
||||
delete productOverrides[productId];
|
||||
}
|
||||
|
||||
const nextSettings = { ...planningSettings, productOverrides };
|
||||
setIsSavingPlanning(true);
|
||||
try {
|
||||
const savedSettings = await saveCuttingSettings(nextSettings);
|
||||
setPlanningSettings(savedSettings);
|
||||
setIsPlanningModalOpen(false);
|
||||
} finally {
|
||||
setIsSavingPlanning(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading && !details) {
|
||||
return <ProductDetailsSkeleton />;
|
||||
}
|
||||
@@ -181,13 +245,15 @@ const ProductDetails = () => {
|
||||
}
|
||||
|
||||
const { productInfo, chartData, totalSold, totalRevenue, totalOrders = 0, averageTicket = 0, variantBreakdown = [] } = details;
|
||||
const productType = resolveProductType(productInfo.name, planningSettings.productOverrides[productInfo.id]);
|
||||
const productTypeConfig = getProductTypeConfig(productType);
|
||||
const isRefreshing = isLoading && Boolean(details);
|
||||
const isSingleDayRange = formatDateKey(dateRange.start) === formatDateKey(dateRange.end);
|
||||
const isHourlyChart = isSingleDayRange && chartData.some(point => /h$|:/.test(point.date));
|
||||
const dateBucket = isHourlyChart ? 'day' : getAutoDateBucket(dateRange);
|
||||
const periodDays = getRangeDayCount(dateRange);
|
||||
const dailyAverageSold = totalSold / periodDays;
|
||||
const projectedStockDays = dailyAverageSold > 0 ? productInfo.stock / dailyAverageSold : null;
|
||||
const projectedStockDays = dailyAverageSold > 0 ? getPlanningStock(productInfo.stock) / dailyAverageSold : null;
|
||||
const stockActionLabel = projectedStockDays === null
|
||||
? 'Sem venda no período'
|
||||
: projectedStockDays <= 7
|
||||
@@ -278,7 +344,10 @@ const ProductDetails = () => {
|
||||
: formatDateBucketLongLabel(selectedProductPoint.date, dateBucket)
|
||||
: '';
|
||||
const maxVariantQuantity = Math.max(...variantBreakdown.map(variant => variant.quantitySold), 0);
|
||||
|
||||
const variantTotalPages = Math.ceil(variantBreakdown.length / variantItemsPerPage);
|
||||
const safeVariantCurrentPage = Math.min(variantCurrentPage, variantTotalPages || 1);
|
||||
const variantStartIndex = (safeVariantCurrentPage - 1) * variantItemsPerPage;
|
||||
const paginatedVariantBreakdown = variantBreakdown.slice(variantStartIndex, variantStartIndex + variantItemsPerPage);
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||
@@ -292,6 +361,19 @@ const ProductDetails = () => {
|
||||
<div>
|
||||
<div className="text-[10px] font-bold text-zinc-400 dark:text-dark-muted uppercase tracking-widest">ID: #{productInfo.id}</div>
|
||||
<h1 className="text-2xl font-bold text-zinc-900 dark:text-dark-text">{productInfo.name}</h1>
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2">
|
||||
<ProductTypeBadge type={productType} />
|
||||
<span className="text-xs font-semibold text-dark-muted">{productTypeConfig.description}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsPlanningModalOpen(true)}
|
||||
className="inline-flex h-7 w-7 items-center justify-center rounded-lg border border-dark-border bg-dark-input text-dark-muted transition-colors hover:border-brand-primary hover:text-brand-primary cursor-pointer"
|
||||
title={`Editar planejamento do SKU ${productInfo.id}`}
|
||||
aria-label={`Editar planejamento do SKU ${productInfo.id}`}
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -474,6 +556,57 @@ const ProductDetails = () => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<section className="bg-white dark:bg-dark-card border border-zinc-200 dark:border-dark-border rounded-2xl p-6 shadow-sm">
|
||||
<div className="mb-5">
|
||||
<h3 className="text-lg font-bold text-zinc-900 dark:text-dark-text">Composição</h3>
|
||||
{composition && (
|
||||
<p className="mt-1 text-sm font-medium text-zinc-500 dark:text-dark-muted">
|
||||
Para produzir 1 UN de {composition.finishedProductSku || productInfo.id}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{isCompositionLoading ? (
|
||||
<div className="flex h-24 items-center text-sm font-semibold text-zinc-500 dark:text-dark-muted">Carregando composição…</div>
|
||||
) : !composition ? (
|
||||
<div className="flex h-24 items-center justify-center rounded-xl border border-dashed border-dark-border bg-dark-input/30 text-sm font-semibold text-zinc-500 dark:text-dark-muted">
|
||||
Nenhuma composição sincronizada para este produto.
|
||||
</div>
|
||||
) : composition.components.length === 0 ? (
|
||||
<div className="flex h-24 items-center justify-center rounded-xl border border-dashed border-dark-border bg-dark-input/30 text-sm font-semibold text-zinc-500 dark:text-dark-muted">
|
||||
Esta composição não possui insumos.
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto rounded-xl border border-dark-border">
|
||||
<table className="w-full min-w-[640px] text-left text-sm">
|
||||
<thead className="bg-dark-input/60 text-[10px] font-bold uppercase tracking-widest text-dark-muted">
|
||||
<tr>
|
||||
<th className="px-4 py-3">Produto / insumo</th>
|
||||
<th className="px-4 py-3">SKU</th>
|
||||
<th className="px-4 py-3 text-right">Quantidade por unidade</th>
|
||||
<th className="px-4 py-3">Unidade</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-dark-border">
|
||||
{composition.components.map(component => (
|
||||
<tr key={component.id} className="text-dark-text">
|
||||
<td className="px-4 py-3 font-semibold">
|
||||
{component.productId ? (
|
||||
<Link to={`/products/${component.productId}`} className="text-brand-primary hover:underline">
|
||||
{component.componentName}
|
||||
</Link>
|
||||
) : component.componentName}
|
||||
</td>
|
||||
<td className="px-4 py-3 font-mono text-xs text-dark-muted">{component.componentSku || '—'}</td>
|
||||
<td className="px-4 py-3 text-right font-semibold">{formatNumber(component.quantityPerUnit)}</td>
|
||||
<td className="px-4 py-3 text-dark-muted">{component.unit || '—'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<div className="bg-white dark:bg-dark-card border border-zinc-200 dark:border-dark-border rounded-2xl p-6 shadow-sm">
|
||||
<div className="mb-5 flex flex-col gap-2 md:flex-row md:items-end md:justify-between">
|
||||
<div>
|
||||
@@ -490,15 +623,21 @@ const ProductDetails = () => {
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{variantBreakdown.map(variant => {
|
||||
{paginatedVariantBreakdown.map(variant => {
|
||||
const width = maxVariantQuantity ? Math.max(4, (variant.quantitySold / maxVariantQuantity) * 100) : 0;
|
||||
const metadata = parseProductName(variant.name);
|
||||
|
||||
return (
|
||||
<div key={variant.id} className="rounded-xl border border-dark-border bg-dark-input/45 p-4">
|
||||
<div key={variant.id} className={`rounded-xl border p-4 ${
|
||||
variant.id === productInfo.id
|
||||
? 'border-brand-primary/50 bg-brand-primary/10 shadow-sm shadow-brand-primary/10'
|
||||
: 'border-dark-border bg-dark-input/45'
|
||||
}`}>
|
||||
<div className="flex flex-col gap-2 md:flex-row md:items-start md:justify-between">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-bold text-dark-text">{variant.name}</div>
|
||||
<Link to={`/products/${variant.id}`} className="block truncate text-sm font-bold text-dark-text transition-colors hover:text-brand-primary hover:underline" title={`Ver produto ${variant.name}`}>
|
||||
{variant.name}
|
||||
</Link>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-2 text-[11px] font-medium text-dark-muted">
|
||||
<span>#{variant.id}</span>
|
||||
{metadata.color && (
|
||||
@@ -539,8 +678,37 @@ const ProductDetails = () => {
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{variantBreakdown.length > variantItemsPerPage && (
|
||||
<PaginationControls
|
||||
totalItems={variantBreakdown.length}
|
||||
currentPage={safeVariantCurrentPage}
|
||||
totalPages={variantTotalPages}
|
||||
pageSize={variantItemsPerPage}
|
||||
pageSizeOptions={[5, 10, 20]}
|
||||
itemLabel="variações"
|
||||
pageSizeLabel="por página"
|
||||
startIndex={variantStartIndex}
|
||||
endIndex={Math.min(variantStartIndex + variantItemsPerPage, variantBreakdown.length)}
|
||||
onPageChange={setVariantCurrentPage}
|
||||
onPageSizeChange={(pageSize) => {
|
||||
setVariantItemsPerPage(pageSize);
|
||||
setVariantCurrentPage(1);
|
||||
}}
|
||||
className="mt-5 border-t border-dark-border pt-4"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isPlanningModalOpen && (
|
||||
<SkuPlanningModal
|
||||
product={productInfo}
|
||||
override={planningSettings.productOverrides[productInfo.id]}
|
||||
isSaving={isSavingPlanning}
|
||||
onClose={() => setIsPlanningModalOpen(false)}
|
||||
onSave={(override) => saveProductOverride(productInfo.id, override)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -5,14 +5,17 @@ import BackButton from '../components/BackButton';
|
||||
import DateRangePicker from '../components/DateRangePicker';
|
||||
import PaginationControls from '../components/PaginationControls';
|
||||
import ProductColorBadge, { ProductColorSwatch } from '../components/ProductColorBadge';
|
||||
import ProductTypeBadge from '../components/ProductTypeBadge';
|
||||
import RefreshStatus from '../components/RefreshStatus';
|
||||
import { buildSkuEditPath } from '../catalogLinks';
|
||||
import { buildOpenProductionByProductId } from '../analytics/cutting';
|
||||
import { fetchProductAnalytics, fetchProductionOrders } from '../dataService';
|
||||
import { fetchCuttingSettings, fetchProductAnalytics, fetchProductionOrders } from '../dataService';
|
||||
import { decodeProductGroupKey, normalizeProductText, parseProductName } from '../productParsing';
|
||||
import { formatColorLabel } from '../displayFormatters';
|
||||
import { getProductColor } from '../productColors';
|
||||
import type { DateRange, ProductAnalyticsItem, ProductionOrderItem } from '../types';
|
||||
import { getDominantProductType, getProductTypeConfig, resolveProductType, type ProductTypeKey } from '../productClassification';
|
||||
import { getPlanningStock } from '../planningStock';
|
||||
import type { CuttingSettings, DateRange, ProductAnalyticsItem, ProductionOrderItem } from '../types';
|
||||
|
||||
type VariantRow = ProductAnalyticsItem & {
|
||||
color: string;
|
||||
@@ -23,6 +26,7 @@ type VariantRow = ProductAnalyticsItem & {
|
||||
availableQuantity: number;
|
||||
suggestedReplenishment: number;
|
||||
daysOfCover: number | null;
|
||||
productType: ProductTypeKey;
|
||||
};
|
||||
|
||||
type BreakdownRow = {
|
||||
@@ -191,6 +195,7 @@ const ProductGroupDetails = () => {
|
||||
const [products, setProducts] = useState<ProductAnalyticsItem[]>([]);
|
||||
const [productionOrders, setProductionOrders] = useState<ProductionOrderItem[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [planningSettings, setPlanningSettings] = useState<CuttingSettings>({ familyYields: {}, productOverrides: {} });
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [itemsPerPage, setItemsPerPage] = useState(20);
|
||||
|
||||
@@ -204,6 +209,21 @@ const ProductGroupDetails = () => {
|
||||
}
|
||||
}, [groupKey]);
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
|
||||
const loadPlanningSettings = async () => {
|
||||
const settings = await fetchCuttingSettings();
|
||||
if (isMounted) setPlanningSettings(settings);
|
||||
};
|
||||
|
||||
void loadPlanningSettings();
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
|
||||
@@ -240,10 +260,11 @@ const ProductGroupDetails = () => {
|
||||
return products
|
||||
.map(product => {
|
||||
const metadata = parseProductName(product.name);
|
||||
const productType = resolveProductType(product.name, planningSettings.productOverrides[product.id]);
|
||||
const dailySales = product.quantitySold / rangeDays;
|
||||
const projectedDemand = dailySales * REPLENISHMENT_TARGET_DAYS;
|
||||
const openProductionQuantity = openProductionByProductId[product.id] || 0;
|
||||
const availableQuantity = product.stock + openProductionQuantity;
|
||||
const availableQuantity = getPlanningStock(product.stock) + openProductionQuantity;
|
||||
const suggestedReplenishment = Math.max(0, Math.ceil(projectedDemand - availableQuantity));
|
||||
|
||||
return {
|
||||
@@ -256,12 +277,13 @@ const ProductGroupDetails = () => {
|
||||
openProductionQuantity,
|
||||
availableQuantity,
|
||||
suggestedReplenishment,
|
||||
daysOfCover: dailySales > 0 ? availableQuantity / dailySales : null
|
||||
daysOfCover: dailySales > 0 ? availableQuantity / dailySales : null,
|
||||
productType
|
||||
};
|
||||
})
|
||||
.filter(product => normalizeProductText(product.baseName).toLowerCase() === normalizedGroupName)
|
||||
.sort((a, b) => b.quantitySold - a.quantitySold);
|
||||
}, [dateRange, groupName, openProductionByProductId, products]);
|
||||
}, [dateRange, groupName, openProductionByProductId, planningSettings.productOverrides, products]);
|
||||
|
||||
const totals = useMemo(() => {
|
||||
const totalSold = groupRows.reduce((total, row) => total + row.quantitySold, 0);
|
||||
@@ -275,6 +297,7 @@ const ProductGroupDetails = () => {
|
||||
const daysOfCover = dailySales > 0 ? availableQuantity / dailySales : null;
|
||||
const colors = new Set(groupRows.map(row => row.color).filter(Boolean));
|
||||
const sizes = new Set(groupRows.map(row => row.size).filter(Boolean));
|
||||
const productType = getDominantProductType(groupRows);
|
||||
|
||||
return {
|
||||
totalSold,
|
||||
@@ -287,9 +310,11 @@ const ProductGroupDetails = () => {
|
||||
suggestedReplenishment,
|
||||
daysOfCover,
|
||||
colorCount: colors.size,
|
||||
sizeCount: sizes.size
|
||||
sizeCount: sizes.size,
|
||||
productType
|
||||
};
|
||||
}, [groupRows]);
|
||||
const productTypeConfig = getProductTypeConfig(totals.productType);
|
||||
|
||||
const colorBreakdown = useMemo(() => buildBreakdown(groupRows, 'color'), [groupRows]);
|
||||
const sizeBreakdown = useMemo(() => buildBreakdown(groupRows, 'size'), [groupRows]);
|
||||
@@ -335,6 +360,10 @@ const ProductGroupDetails = () => {
|
||||
Grupo · {formatNumber(groupRows.length)} SKUs · {formatNumber(totals.colorCount)} cores · {formatNumber(totals.sizeCount)} tamanhos
|
||||
</p>
|
||||
<h1 className="truncate text-2xl font-bold text-zinc-900 dark:text-dark-text" title={groupName}>{groupName}</h1>
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2">
|
||||
<ProductTypeBadge type={totals.productType} />
|
||||
<span className="text-xs font-semibold text-dark-muted">{productTypeConfig.description}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -522,7 +551,10 @@ const ProductGroupDetails = () => {
|
||||
<td className="px-6 py-2.5 font-mono text-[11px] text-zinc-400 dark:text-dark-muted">#{row.id}</td>
|
||||
<td className="max-w-0 px-6 py-2.5">
|
||||
<div className="truncate font-semibold text-zinc-900 dark:text-dark-text" title={row.name}>{row.name}</div>
|
||||
<div className="text-[10px] font-medium text-zinc-400 dark:text-dark-muted">Preço Atual: {formatCurrency(row.lastPrice)}</div>
|
||||
<div className="mt-1 flex min-w-0 items-center gap-2">
|
||||
<ProductTypeBadge type={row.productType} />
|
||||
<span className="truncate text-[10px] font-medium text-zinc-400 dark:text-dark-muted">Preço Atual: {formatCurrency(row.lastPrice)}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-2.5">
|
||||
<ProductColorBadge label={row.color} className="max-w-[8rem]" />
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { type FormEvent, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Fragment, type FormEvent, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Link, useOutletContext } from 'react-router-dom';
|
||||
import { ArrowLeft, CalendarDays, CheckCircle2, ClipboardList, Clock3, Download, PackageCheck, Search } from 'lucide-react';
|
||||
import DateRangePicker from '../components/DateRangePicker';
|
||||
import PaginationControls from '../components/PaginationControls';
|
||||
import RefreshStatus from '../components/RefreshStatus';
|
||||
import { consumeSupplyLotForProduction, exportToCSV, fetchProductionOrders, fetchSupplySummary } from '../dataService';
|
||||
import { consumeSupplyLotForProduction, exportToCSV, fetchProductionOrders, fetchSupplySummary, updateProductionOrderStatus } from '../dataService';
|
||||
import type { DateRange, ProductionOrderItem, ProductionOrderStatus, ProductionOrderSummary, SupplyLot } from '../types';
|
||||
|
||||
type ProductionOrderStatusTab = 'all' | 'open' | 'in_progress' | 'finished' | 'canceled';
|
||||
@@ -22,6 +22,10 @@ const statusTabs: Array<{ key: ProductionOrderStatusTab; label: string; dotClass
|
||||
{ key: 'canceled', label: 'Cancelada', dotClass: 'bg-zinc-500' }
|
||||
];
|
||||
|
||||
const editableStatusOptions: Array<{ value: ProductionOrderStatusTab; label: string }> = statusTabs
|
||||
.filter(tab => tab.key !== 'all')
|
||||
.map(tab => ({ value: tab.key, label: tab.label }));
|
||||
|
||||
const statusStyles: Record<string, { label: string; className: string; dotClass: string }> = {
|
||||
open: {
|
||||
label: 'Em aberto',
|
||||
@@ -59,6 +63,16 @@ const formatQuantity = (value: number) => (
|
||||
}).format(value)
|
||||
);
|
||||
|
||||
const formatOptionalQuantity = (value: number | null) => (
|
||||
value === null ? '-' : formatQuantity(value)
|
||||
);
|
||||
|
||||
const hasProductionOrderDetails = (order: ProductionOrderItem) => (
|
||||
order.components.length > 0 ||
|
||||
order.steps.length > 0 ||
|
||||
Boolean(order.notes || order.supplier || order.lotCode || order.rollQuantity || order.fabricKg || order.ribKg || order.yieldPiecesPerKg)
|
||||
);
|
||||
|
||||
const getStatusStyle = (status: ProductionOrderStatus, fallbackLabel: string) => (
|
||||
statusStyles[String(status)] || {
|
||||
label: fallbackLabel || 'Em aberto',
|
||||
@@ -121,6 +135,8 @@ const ProductionOrders = () => {
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isSupplyBusy, setIsSupplyBusy] = useState(false);
|
||||
const [supplyMessage, setSupplyMessage] = useState('');
|
||||
const [busyStatusOrderId, setBusyStatusOrderId] = useState<number | null>(null);
|
||||
const [statusMessage, setStatusMessage] = useState('');
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [itemsPerPage, setItemsPerPage] = useState(20);
|
||||
const [exitForm, setExitForm] = useState({
|
||||
@@ -185,11 +201,29 @@ const ProductionOrders = () => {
|
||||
const progressCount = summary.counts.in_progress || 0;
|
||||
const finishedCount = summary.counts.finished || 0;
|
||||
const totalQuantity = summary.orders.reduce((total, order) => total + order.quantity, 0);
|
||||
const activeOrders = summary.orders.filter(order => order.status !== 'finished' && order.status !== 'canceled');
|
||||
|
||||
const handleManualRefresh = () => {
|
||||
void loadProductionOrders({ force: true });
|
||||
};
|
||||
|
||||
const handleStatusChange = async (order: ProductionOrderItem, status: ProductionOrderStatusTab) => {
|
||||
if (status === 'all' || status === order.status) return;
|
||||
|
||||
setBusyStatusOrderId(order.id);
|
||||
setStatusMessage('');
|
||||
try {
|
||||
await updateProductionOrderStatus(order.id, status);
|
||||
const nextSummary = await fetchProductionOrders(dateRange, { search: searchTerm }, { force: true });
|
||||
setSummary(nextSummary);
|
||||
setStatusMessage(`OP ${order.number || `#${order.id}`} atualizada para ${statusStyles[status]?.label || status}.`);
|
||||
} catch (error) {
|
||||
setStatusMessage(error instanceof Error ? error.message : 'Não foi possível atualizar o status da OP.');
|
||||
} finally {
|
||||
setBusyStatusOrderId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const refreshSupplyLots = async () => {
|
||||
const supplySummary = await fetchSupplySummary();
|
||||
setSupplyLots(supplySummary.lots);
|
||||
@@ -248,7 +282,7 @@ const ProductionOrders = () => {
|
||||
Suprimentos
|
||||
</Link>
|
||||
<h1 className="text-2xl font-bold text-dark-text">Ordens de Produção</h1>
|
||||
<p className="mt-2 text-dark-muted font-medium">Acompanhe as ordens de produção sincronizadas do Tiny.</p>
|
||||
<p className="mt-2 text-dark-muted font-medium">Acompanhe as ordens locais geradas pelo plano de corte.</p>
|
||||
</div>
|
||||
<DateRangePicker
|
||||
dateRange={dateRange}
|
||||
@@ -319,6 +353,7 @@ const ProductionOrders = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{activeOrders.length > 0 && (
|
||||
<form onSubmit={handleProductionExit} className="rounded-2xl border border-dark-border bg-dark-card p-5 shadow-sm">
|
||||
<div className="flex flex-col gap-2 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div>
|
||||
@@ -340,7 +375,7 @@ const ProductionOrders = () => {
|
||||
className="mt-1 h-10 w-full rounded-lg border border-dark-border bg-dark-input px-3 text-sm font-semibold text-dark-text outline-none focus:border-brand-primary"
|
||||
>
|
||||
<option value="">Selecione...</option>
|
||||
{summary.orders.filter(order => order.status !== 'finished' && order.status !== 'canceled').map(order => (
|
||||
{activeOrders.map(order => (
|
||||
<option key={order.id} value={order.id}>
|
||||
{order.number || `#${order.id}`} · {order.productDescription}
|
||||
</option>
|
||||
@@ -396,9 +431,15 @@ const ProductionOrders = () => {
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
)}
|
||||
|
||||
<div className="overflow-hidden rounded-2xl border border-dark-border bg-dark-card shadow-sm">
|
||||
<div className="border-b border-dark-border p-5">
|
||||
{statusMessage && (
|
||||
<div className="mb-4 rounded-xl border border-dark-border bg-dark-input px-3 py-2 text-sm font-bold text-dark-muted">
|
||||
{statusMessage}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col gap-4 xl:flex-row xl:items-center xl:justify-between">
|
||||
<div className="relative w-full xl:max-w-xl">
|
||||
<Search className="absolute left-3 top-1/2 h-5 w-5 -translate-y-1/2 text-dark-muted" />
|
||||
@@ -472,8 +513,10 @@ const ProductionOrders = () => {
|
||||
<tbody className="divide-y divide-dark-border">
|
||||
{paginatedOrders.map((order: ProductionOrderItem) => {
|
||||
const statusStyle = getStatusStyle(order.status, order.statusLabel);
|
||||
const showDetails = hasProductionOrderDetails(order);
|
||||
return (
|
||||
<tr key={order.id} className="transition-colors hover:bg-dark-input/50">
|
||||
<Fragment key={order.id}>
|
||||
<tr className="transition-colors hover:bg-dark-input/50">
|
||||
<td className="px-6 py-3 font-mono text-xs font-bold text-dark-text">{order.number || '-'}</td>
|
||||
<td className="px-6 py-3 text-xs font-semibold text-dark-muted">{order.orderReference || '-'}</td>
|
||||
<td className="px-6 py-3 text-xs font-semibold text-dark-muted">
|
||||
@@ -515,12 +558,91 @@ const ProductionOrders = () => {
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-3">
|
||||
<span className={`inline-flex items-center gap-2 rounded-full border px-2.5 py-1 text-[10px] font-bold uppercase tracking-wide ${statusStyle.className}`}>
|
||||
<span className={`h-1.5 w-1.5 rounded-full ${statusStyle.dotClass}`} />
|
||||
{statusStyle.label}
|
||||
</span>
|
||||
<label className="sr-only" htmlFor={`production-order-status-${order.id}`}>Status da OP</label>
|
||||
<select
|
||||
id={`production-order-status-${order.id}`}
|
||||
value={order.status}
|
||||
disabled={busyStatusOrderId === order.id}
|
||||
onChange={(event) => void handleStatusChange(order, event.target.value as ProductionOrderStatusTab)}
|
||||
className={`h-8 rounded-lg border bg-dark-input px-2 text-[10px] font-bold uppercase tracking-wide outline-none transition-colors focus:border-brand-primary disabled:cursor-not-allowed disabled:opacity-50 ${statusStyle.className}`}
|
||||
>
|
||||
{editableStatusOptions.map(option => (
|
||||
<option key={option.value} value={option.value}>{option.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</td>
|
||||
</tr>
|
||||
{showDetails && (
|
||||
<tr className="bg-dark-input/20">
|
||||
<td colSpan={9} className="px-6 pb-5 pt-1">
|
||||
<div className="grid grid-cols-1 gap-4 rounded-xl border border-dark-border bg-dark-card/80 p-4 xl:grid-cols-[1.4fr_1fr_1fr]">
|
||||
<div>
|
||||
<h3 className="text-xs font-bold uppercase tracking-widest text-dark-muted">Composição</h3>
|
||||
{order.components.length ? (
|
||||
<div className="mt-3 overflow-hidden rounded-lg border border-dark-border">
|
||||
<table className="w-full text-xs">
|
||||
<thead className="bg-dark-header text-dark-muted">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left font-bold">Produto</th>
|
||||
<th className="px-3 py-2 text-left font-bold">SKU</th>
|
||||
<th className="px-3 py-2 text-right font-bold">Qtd.</th>
|
||||
<th className="px-3 py-2 text-right font-bold">Total</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-dark-border">
|
||||
{order.components.map(component => (
|
||||
<tr key={component.id}>
|
||||
<td className="px-3 py-2 font-semibold text-dark-text">{component.componentName}</td>
|
||||
<td className="px-3 py-2 font-mono text-dark-muted">{component.componentSku || '-'}</td>
|
||||
<td className="px-3 py-2 text-right font-semibold text-dark-muted">{formatQuantity(component.quantityPerUnit)} {component.unit}</td>
|
||||
<td className="px-3 py-2 text-right font-bold text-dark-text">{formatQuantity(component.totalQuantity)} {component.unit}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<p className="mt-3 text-xs font-semibold text-dark-muted">Sem composição sincronizada.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-xs font-bold uppercase tracking-widest text-dark-muted">Etapas</h3>
|
||||
{order.steps.length ? (
|
||||
<div className="mt-3 divide-y divide-dark-border rounded-lg border border-dark-border">
|
||||
{order.steps.map(step => (
|
||||
<div key={step.id} className="grid grid-cols-[32px_1fr_auto] items-center gap-3 px-3 py-2 text-xs">
|
||||
<span className="font-mono font-bold text-dark-muted">{step.stepNumber ?? '-'}</span>
|
||||
<div>
|
||||
<p className="font-bold text-dark-text">{step.name}</p>
|
||||
<p className="mt-0.5 font-semibold text-dark-muted">{formatDate(step.startDate)} - {formatDate(step.endDate)}</p>
|
||||
</div>
|
||||
<span className="h-2.5 w-2.5 rounded-full" style={{ backgroundColor: step.color || 'var(--color-brand-primary)' }} title={step.status || 'Sem status'} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="mt-3 text-xs font-semibold text-dark-muted">Sem etapas sincronizadas.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-xs font-bold uppercase tracking-widest text-dark-muted">Observações</h3>
|
||||
<dl className="mt-3 grid grid-cols-2 gap-2 text-xs">
|
||||
<div><dt className="font-bold text-dark-muted">Fornecedor</dt><dd className="mt-0.5 font-semibold text-dark-text">{order.supplier || '-'}</dd></div>
|
||||
<div><dt className="font-bold text-dark-muted">Lote</dt><dd className="mt-0.5 font-semibold text-dark-text">{order.lotCode || '-'}</dd></div>
|
||||
<div><dt className="font-bold text-dark-muted">Rolos</dt><dd className="mt-0.5 font-semibold text-dark-text">{formatOptionalQuantity(order.rollQuantity)}</dd></div>
|
||||
<div><dt className="font-bold text-dark-muted">Malha kg</dt><dd className="mt-0.5 font-semibold text-dark-text">{formatOptionalQuantity(order.fabricKg)}</dd></div>
|
||||
<div><dt className="font-bold text-dark-muted">Ribana kg</dt><dd className="mt-0.5 font-semibold text-dark-text">{formatOptionalQuantity(order.ribKg)}</dd></div>
|
||||
<div><dt className="font-bold text-dark-muted">Rendimento</dt><dd className="mt-0.5 font-semibold text-dark-text">{formatOptionalQuantity(order.yieldPiecesPerKg)}</dd></div>
|
||||
</dl>
|
||||
{order.notes && <p className="mt-3 whitespace-pre-wrap rounded-lg border border-dark-border bg-dark-input p-3 text-xs font-semibold text-dark-muted">{order.notes}</p>}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
@@ -534,8 +656,15 @@ const ProductionOrders = () => {
|
||||
</div>
|
||||
<p className="mt-4 text-sm font-bold text-dark-text">Nenhuma ordem de produção encontrada.</p>
|
||||
<p className="mt-1 max-w-md text-sm font-medium text-dark-muted">
|
||||
Quando a sincronização com o Tiny estiver ativa, as ordens aparecerão aqui com status, produto, quantidade e marcadores.
|
||||
Gere ordens pelo Plano de Corte para acompanhar status, produto, quantidade e baixa de material dentro do Graphs.
|
||||
</p>
|
||||
<Link
|
||||
to="/cutting"
|
||||
className="mt-5 inline-flex h-10 items-center gap-2 rounded-lg border border-brand-primary/35 bg-brand-primary/10 px-4 text-sm font-bold text-brand-primary transition-colors hover:bg-brand-primary/20"
|
||||
>
|
||||
<ClipboardList className="h-4 w-4" />
|
||||
Ir para Plano de Corte
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,20 +1,24 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Link, useOutletContext } from 'react-router-dom';
|
||||
import { Download, Eye, Filter, Package, PackageCheck, Pencil, Search, TrendingDown, X } from 'lucide-react';
|
||||
import { buildSkuEditPath } from '../catalogLinks';
|
||||
import DateRangePicker from '../components/DateRangePicker';
|
||||
import PaginationControls from '../components/PaginationControls';
|
||||
import SkuPlanningModal from '../components/SkuPlanningModal';
|
||||
import ProductTypeBadge from '../components/ProductTypeBadge';
|
||||
import RefreshStatus from '../components/RefreshStatus';
|
||||
import type { DateRange, ProductAnalyticsItem } from '../types';
|
||||
import { exportToCSV, fetchProductAnalytics } from '../dataService';
|
||||
import type { CutProductOverride, CuttingSettings, DateRange, ProductAnalyticsItem } from '../types';
|
||||
import { exportProductCompositions, exportToCSV, fetchCuttingSettings, fetchProductAnalytics, saveCuttingSettings } from '../dataService';
|
||||
import { encodeProductGroupKey, parseProductName, sortProductSizes } from '../productParsing';
|
||||
import { formatColorLabel } from '../displayFormatters';
|
||||
import { getDominantProductType, getProductTypeConfig, productTypeOptions, resolveProductType, type ProductTypeKey } from '../productClassification';
|
||||
import { getPlanningStock } from '../planningStock';
|
||||
|
||||
type StockRisk = 'rupture' | 'critical' | 'attention' | 'monitor' | 'healthy' | 'no_sales';
|
||||
type StockStatusFilter = 'all' | StockRisk;
|
||||
type StockQuantityFilter = 'all' | 'zero' | 'positive' | 'low' | 'high';
|
||||
type SalesFilter = 'all' | 'sold' | 'not_sold';
|
||||
type CoverageFilter = 'all' | 'up_to_7' | 'up_to_14' | 'up_to_30' | 'over_30' | 'none';
|
||||
type ProductTypeFilter = 'all' | ProductTypeKey;
|
||||
type ProductSortOption = 'sold_desc' | 'sold_asc' | 'stock_priority' | 'revenue_desc' | 'revenue_asc' | 'stock_asc' | 'stock_desc' | 'coverage_asc' | 'coverage_desc' | 'name_asc';
|
||||
type ProductViewMode = 'sku' | 'group';
|
||||
|
||||
@@ -33,6 +37,7 @@ type ProductRow = ProductAnalyticsItem & {
|
||||
topColor: string;
|
||||
topSize: string;
|
||||
groupKey: string;
|
||||
productType: ProductTypeKey;
|
||||
};
|
||||
|
||||
const riskStyles: Record<StockRisk, { label: string; className: string; dotClass: string }> = {
|
||||
@@ -163,15 +168,37 @@ const Products = () => {
|
||||
const [stockQuantityFilter, setStockQuantityFilter] = useState<StockQuantityFilter>('all');
|
||||
const [salesFilter, setSalesFilter] = useState<SalesFilter>('all');
|
||||
const [coverageFilter, setCoverageFilter] = useState<CoverageFilter>('all');
|
||||
const [productTypeFilter, setProductTypeFilter] = useState<ProductTypeFilter>('all');
|
||||
const [viewMode, setViewMode] = useState<ProductViewMode>('sku');
|
||||
const [isFilterMenuOpen, setIsFilterMenuOpen] = useState(false);
|
||||
const [isExportMenuOpen, setIsExportMenuOpen] = useState(false);
|
||||
const filterMenuRef = useRef<HTMLDivElement>(null);
|
||||
const exportMenuRef = useRef<HTMLDivElement>(null);
|
||||
const [productAnalytics, setProductAnalytics] = useState<ProductAnalyticsItem[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [planningSettings, setPlanningSettings] = useState<CuttingSettings>({ familyYields: {}, productOverrides: {} });
|
||||
const [editingProduct, setEditingProduct] = useState<ProductRow | null>(null);
|
||||
const [isSavingPlanning, setIsSavingPlanning] = useState(false);
|
||||
const [isExportingCompositions, setIsExportingCompositions] = useState(false);
|
||||
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [itemsPerPage, setItemsPerPage] = useState(10);
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
|
||||
const loadPlanningSettings = async () => {
|
||||
const settings = await fetchCuttingSettings();
|
||||
if (isMounted) setPlanningSettings(settings);
|
||||
};
|
||||
|
||||
void loadPlanningSettings();
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
|
||||
@@ -192,17 +219,40 @@ const Products = () => {
|
||||
};
|
||||
}, [dateRange]);
|
||||
|
||||
const saveProductOverride = async (productId: string, override: CutProductOverride | null) => {
|
||||
const productOverrides = { ...planningSettings.productOverrides };
|
||||
if (override) {
|
||||
productOverrides[productId] = override;
|
||||
} else {
|
||||
delete productOverrides[productId];
|
||||
}
|
||||
|
||||
const nextSettings = { ...planningSettings, productOverrides };
|
||||
setIsSavingPlanning(true);
|
||||
try {
|
||||
const savedSettings = await saveCuttingSettings(nextSettings);
|
||||
setPlanningSettings(savedSettings);
|
||||
setEditingProduct(null);
|
||||
} finally {
|
||||
setIsSavingPlanning(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!isFilterMenuOpen) return;
|
||||
if (!isFilterMenuOpen && !isExportMenuOpen) return;
|
||||
|
||||
const handlePointerDown = (event: PointerEvent) => {
|
||||
if (!filterMenuRef.current?.contains(event.target as Node)) {
|
||||
setIsFilterMenuOpen(false);
|
||||
}
|
||||
if (!exportMenuRef.current?.contains(event.target as Node)) {
|
||||
setIsExportMenuOpen(false);
|
||||
}
|
||||
};
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
setIsFilterMenuOpen(false);
|
||||
setIsExportMenuOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -213,21 +263,23 @@ const Products = () => {
|
||||
document.removeEventListener('pointerdown', handlePointerDown);
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [isFilterMenuOpen]);
|
||||
}, [isFilterMenuOpen, isExportMenuOpen]);
|
||||
|
||||
const productsData = useMemo<ProductRow[]>(() => {
|
||||
const days = getRangeDays(dateRange);
|
||||
const normalizedSearch = searchTerm.trim().toLowerCase();
|
||||
const skuRows = productAnalytics.map(product => {
|
||||
const dailySales = product.quantitySold / days;
|
||||
const risk = classifyStockRisk(product.stock, dailySales);
|
||||
const planningStock = getPlanningStock(product.stock);
|
||||
const risk = classifyStockRisk(planningStock, dailySales);
|
||||
const style = riskStyles[risk];
|
||||
const metadata = parseProductName(product.name);
|
||||
const productType = resolveProductType(product.name, planningSettings.productOverrides[product.id]);
|
||||
|
||||
return {
|
||||
...product,
|
||||
dailySales,
|
||||
daysOfCover: dailySales > 0 ? product.stock / dailySales : null,
|
||||
daysOfCover: dailySales > 0 ? planningStock / dailySales : null,
|
||||
risk,
|
||||
riskLabel: style.label,
|
||||
baseName: metadata.baseName,
|
||||
@@ -239,7 +291,8 @@ const Products = () => {
|
||||
sizes: metadata.size ? [metadata.size] : [],
|
||||
topColor: metadata.color || '-',
|
||||
topSize: metadata.size || '-',
|
||||
groupKey: encodeProductGroupKey(metadata.baseName)
|
||||
groupKey: encodeProductGroupKey(metadata.baseName),
|
||||
productType
|
||||
};
|
||||
});
|
||||
|
||||
@@ -254,13 +307,15 @@ const Products = () => {
|
||||
|
||||
const groupedRows = Array.from(groups.values()).map(group => {
|
||||
const first = group[0];
|
||||
const productType = getDominantProductType(group);
|
||||
const quantitySold = group.reduce((total, product) => total + product.quantitySold, 0);
|
||||
const revenue = group.reduce((total, product) => total + product.revenue, 0);
|
||||
const stock = group.reduce((total, product) => total + product.stock, 0);
|
||||
const planningStock = group.reduce((total, product) => total + getPlanningStock(product.stock), 0);
|
||||
const orderLineCount = group.reduce((total, product) => total + product.orderLineCount, 0);
|
||||
const dailySales = quantitySold / days;
|
||||
const daysOfCover = dailySales > 0 ? stock / dailySales : null;
|
||||
const risk = classifyStockRisk(stock, dailySales);
|
||||
const daysOfCover = dailySales > 0 ? planningStock / dailySales : null;
|
||||
const risk = classifyStockRisk(planningStock, dailySales);
|
||||
const style = riskStyles[risk];
|
||||
const colorTotals = new Map<string, number>();
|
||||
const sizeTotals = new Map<string, number>();
|
||||
@@ -297,6 +352,7 @@ const Products = () => {
|
||||
sizes,
|
||||
topColor,
|
||||
topSize,
|
||||
productType,
|
||||
lastPrice: quantitySold > 0 ? revenue / quantitySold : first.lastPrice
|
||||
};
|
||||
});
|
||||
@@ -309,7 +365,8 @@ const Products = () => {
|
||||
product.id.toLowerCase().includes(normalizedSearch) ||
|
||||
product.productIds.some(id => id.toLowerCase().includes(normalizedSearch)) ||
|
||||
product.colors.some(color => color.toLowerCase().includes(normalizedSearch)) ||
|
||||
product.sizes.some(size => size.toLowerCase().includes(normalizedSearch))
|
||||
product.sizes.some(size => size.toLowerCase().includes(normalizedSearch)) ||
|
||||
getProductTypeConfig(product.productType).label.toLowerCase().includes(normalizedSearch)
|
||||
)
|
||||
: activeRows;
|
||||
|
||||
@@ -325,6 +382,7 @@ const Products = () => {
|
||||
salesFilter === 'all' ||
|
||||
(salesFilter === 'sold' && product.quantitySold > 0) ||
|
||||
(salesFilter === 'not_sold' && product.quantitySold === 0);
|
||||
const matchesProductType = productTypeFilter === 'all' || product.productType === productTypeFilter;
|
||||
const matchesCoverage =
|
||||
coverageFilter === 'all' ||
|
||||
(coverageFilter === 'none' && product.daysOfCover === null) ||
|
||||
@@ -333,7 +391,7 @@ const Products = () => {
|
||||
(coverageFilter === 'up_to_30' && product.daysOfCover !== null && product.daysOfCover > 14 && product.daysOfCover <= 30) ||
|
||||
(coverageFilter === 'over_30' && product.daysOfCover !== null && product.daysOfCover > 30);
|
||||
|
||||
return matchesStatus && matchesStockQuantity && matchesSales && matchesCoverage;
|
||||
return matchesStatus && matchesStockQuantity && matchesSales && matchesProductType && matchesCoverage;
|
||||
});
|
||||
|
||||
return detailedFilteredProducts.sort((a, b) => {
|
||||
@@ -356,12 +414,13 @@ const Products = () => {
|
||||
return b.quantitySold - a.quantitySold;
|
||||
}
|
||||
});
|
||||
}, [coverageFilter, dateRange, productAnalytics, salesFilter, searchTerm, sortBy, stockQuantityFilter, stockStatusFilter, viewMode]);
|
||||
}, [coverageFilter, dateRange, planningSettings.productOverrides, productAnalytics, productTypeFilter, salesFilter, searchTerm, sortBy, stockQuantityFilter, stockStatusFilter, viewMode]);
|
||||
|
||||
const activeFilterCount =
|
||||
(stockStatusFilter === 'all' ? 0 : 1) +
|
||||
(stockQuantityFilter === 'all' ? 0 : 1) +
|
||||
(salesFilter === 'all' ? 0 : 1) +
|
||||
(productTypeFilter === 'all' ? 0 : 1) +
|
||||
(coverageFilter === 'all' ? 0 : 1);
|
||||
const hasActiveFilters = activeFilterCount > 0;
|
||||
|
||||
@@ -369,6 +428,7 @@ const Products = () => {
|
||||
setStockStatusFilter('all');
|
||||
setStockQuantityFilter('all');
|
||||
setSalesFilter('all');
|
||||
setProductTypeFilter('all');
|
||||
setCoverageFilter('all');
|
||||
setCurrentPage(1);
|
||||
};
|
||||
@@ -400,7 +460,21 @@ const Products = () => {
|
||||
}}
|
||||
/>
|
||||
|
||||
<div ref={exportMenuRef} className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsExportMenuOpen(current => !current)}
|
||||
aria-expanded={isExportMenuOpen}
|
||||
className="flex items-center justify-center gap-2 bg-dark-card border border-dark-border px-4 py-2.5 rounded-xl shadow-sm hover:border-brand-primary transition-colors text-sm font-medium text-dark-text cursor-pointer"
|
||||
title="Escolher exportação"
|
||||
>
|
||||
<Download size={16} className="text-brand-primary" />
|
||||
<span>Exportar</span>
|
||||
</button>
|
||||
{isExportMenuOpen && (
|
||||
<div className="absolute right-0 z-20 mt-2 w-56 overflow-hidden rounded-xl border border-dark-border bg-dark-card p-1 shadow-xl">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const exportData = productsData.map(product => ({
|
||||
'Tipo': viewMode === 'group' ? 'Grupo' : 'SKU',
|
||||
@@ -409,6 +483,7 @@ const Products = () => {
|
||||
'SKUs': product.skuCount,
|
||||
'Cores': product.colors.join(' | '),
|
||||
'Tamanhos': product.sizes.join(' | '),
|
||||
'Tipo de produto': getProductTypeConfig(product.productType).label,
|
||||
'Cor principal': product.topColor,
|
||||
'Tamanho principal': product.topSize,
|
||||
'Status': product.riskLabel,
|
||||
@@ -420,13 +495,33 @@ const Products = () => {
|
||||
'Receita Gerada (R$)': product.revenue.toFixed(2).replace('.', ',')
|
||||
}));
|
||||
exportToCSV(exportData, `${viewMode === 'group' ? 'grupos_produtos' : 'produtos'}_${new Date().toISOString().split('T')[0]}.csv`);
|
||||
setIsExportMenuOpen(false);
|
||||
}}
|
||||
className="flex items-center justify-center gap-2 bg-dark-card border border-dark-border px-4 py-2.5 rounded-xl shadow-sm hover:border-brand-primary transition-colors text-sm font-medium text-dark-text cursor-pointer"
|
||||
title="Exportar para CSV"
|
||||
className="w-full rounded-lg px-3 py-2 text-left text-sm font-semibold text-dark-text hover:bg-dark-input cursor-pointer"
|
||||
>
|
||||
<Download size={16} className="text-brand-primary" />
|
||||
<span className="hidden sm:inline">Exportar</span>
|
||||
Produtos (CSV)
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={isExportingCompositions}
|
||||
onClick={async () => {
|
||||
setIsExportingCompositions(true);
|
||||
try {
|
||||
await exportProductCompositions();
|
||||
setIsExportMenuOpen(false);
|
||||
} catch (error) {
|
||||
window.alert(error instanceof Error ? error.message : 'Não foi possível exportar as composições.');
|
||||
} finally {
|
||||
setIsExportingCompositions(false);
|
||||
}
|
||||
}}
|
||||
className="w-full rounded-lg px-3 py-2 text-left text-sm font-semibold text-dark-text hover:bg-dark-input cursor-pointer disabled:cursor-wait disabled:opacity-60"
|
||||
>
|
||||
{isExportingCompositions ? 'Preparando…' : 'Composições (JSON)'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -504,6 +599,23 @@ const Products = () => {
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||||
<label className="block text-xs font-bold uppercase tracking-wider text-dark-muted">
|
||||
Tipo de produto
|
||||
<select
|
||||
value={productTypeFilter}
|
||||
onChange={(event) => {
|
||||
setProductTypeFilter(event.target.value as ProductTypeFilter);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
className={`${filterSelectClassName} mt-1`}
|
||||
>
|
||||
<option value="all">Todos</option>
|
||||
{productTypeOptions.map(option => (
|
||||
<option key={option.value} value={option.value}>{option.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="block text-xs font-bold uppercase tracking-wider text-dark-muted">
|
||||
Status
|
||||
<select
|
||||
@@ -657,10 +769,13 @@ const Products = () => {
|
||||
</td>
|
||||
<td className="max-w-0 px-6 py-2.5">
|
||||
<div className="truncate font-semibold text-zinc-900 dark:text-dark-text" title={product.name}>{product.name}</div>
|
||||
<div className="truncate text-[10px] text-zinc-400 dark:text-dark-muted font-medium">
|
||||
<div className="mt-1 flex min-w-0 items-center gap-2">
|
||||
<ProductTypeBadge type={product.productType} />
|
||||
<span className="truncate text-[10px] font-medium text-zinc-400 dark:text-dark-muted">
|
||||
{viewMode === 'group'
|
||||
? `Cor principal: ${formatColorLabel(product.topColor)} · Tam. principal: ${product.topSize} · ${product.colors.length} cores · ${product.sizes.length} tamanhos`
|
||||
: `Preço Atual: ${formatCurrency(product.lastPrice)}`}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-2.5">
|
||||
@@ -687,14 +802,15 @@ const Products = () => {
|
||||
<td className="px-4 py-2.5 text-right">
|
||||
<div className="flex justify-end gap-2">
|
||||
{viewMode === 'sku' && (
|
||||
<Link
|
||||
to={buildSkuEditPath({ sku: product.id, name: product.name, color: product.color, size: product.size })}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditingProduct(product)}
|
||||
className="inline-flex h-8 w-8 items-center justify-center rounded-lg bg-dark-input text-dark-text transition-colors hover:bg-dark-border cursor-pointer"
|
||||
title={`Editar SKU ${product.id}`}
|
||||
aria-label={`Editar SKU ${product.id}`}
|
||||
title={`Editar planejamento do SKU ${product.id}`}
|
||||
aria-label={`Editar planejamento do SKU ${product.id}`}
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</Link>
|
||||
</button>
|
||||
)}
|
||||
<Link
|
||||
to={viewMode === 'group' ? `/products/groups/${product.groupKey}` : `/products/${product.id}`}
|
||||
@@ -739,6 +855,16 @@ const Products = () => {
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{editingProduct && (
|
||||
<SkuPlanningModal
|
||||
product={editingProduct}
|
||||
override={planningSettings.productOverrides[editingProduct.id]}
|
||||
isSaving={isSavingPlanning}
|
||||
onClose={() => setEditingProduct(null)}
|
||||
onSave={(override) => saveProductOverride(editingProduct.id, override)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { Loader2, Package, RefreshCw, Ruler, Save, Tags, Trash2 } from 'lucide-react';
|
||||
import { Link, useSearchParams } from 'react-router-dom';
|
||||
import { ClipboardList, Loader2, Package, RefreshCw, Ruler, Save, Tags, Trash2, Upload } from 'lucide-react';
|
||||
import {
|
||||
deleteCatalogCategory,
|
||||
deleteCatalogProduct,
|
||||
deleteConsumptionReference,
|
||||
fetchCatalogSummary,
|
||||
importProductCompositions,
|
||||
saveCatalogCategory,
|
||||
saveCatalogProduct,
|
||||
saveConsumptionReference
|
||||
@@ -92,6 +93,18 @@ const getAverageYield = (reference: ConsumptionReference) => {
|
||||
return reference.generalYield;
|
||||
};
|
||||
|
||||
const getReferenceSourceLabel = (reference: ConsumptionReference) => (
|
||||
reference.source === 'tiny_op'
|
||||
? 'Tiny OP'
|
||||
: reference.source === 'tiny_structure' ? 'Tiny Estrutura' : 'Manual'
|
||||
);
|
||||
|
||||
const formatConsumptionPerPiece = (reference: ConsumptionReference) => {
|
||||
if (!reference.consumptionQuantity) return '';
|
||||
const unit = reference.consumptionUnit || 'un.';
|
||||
return `${formatNumber(reference.consumptionQuantity, 4)} ${unit}/peça`;
|
||||
};
|
||||
|
||||
const Registrations = () => {
|
||||
const [searchParams] = useSearchParams();
|
||||
const [catalog, setCatalog] = useState<CatalogSummary>(emptyCatalog);
|
||||
@@ -101,6 +114,7 @@ const Registrations = () => {
|
||||
const [status, setStatus] = useState<SaveStatus>('idle');
|
||||
const [feedback, setFeedback] = useState('');
|
||||
const appliedSkuPrefillRef = useRef('');
|
||||
const compositionFileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const [categoryForm, setCategoryForm] = useState({ name: '', description: '' });
|
||||
const [productForm, setProductForm] = useState(defaultProductForm);
|
||||
@@ -313,6 +327,29 @@ const Registrations = () => {
|
||||
setSizeYields({});
|
||||
}, 'Referencia salva.');
|
||||
|
||||
const importCompositionFile = async (file: File | null) => {
|
||||
if (!file) return;
|
||||
|
||||
setStatus('saving');
|
||||
setFeedback('Importando composições...');
|
||||
|
||||
try {
|
||||
const payload = JSON.parse(await file.text()) as unknown;
|
||||
const result = await importProductCompositions(payload);
|
||||
await loadCatalog();
|
||||
setStatus(result.failed ? 'error' : 'saved');
|
||||
setFeedback(
|
||||
`${result.imported} composições importadas, ${result.referenceCount} referências criadas/atualizadas` +
|
||||
`${result.failed ? `, ${result.failed} falhas` : ''}.`
|
||||
);
|
||||
} catch (error) {
|
||||
setStatus('error');
|
||||
setFeedback(error instanceof Error ? error.message : 'Não foi possível importar o arquivo.');
|
||||
} finally {
|
||||
if (compositionFileInputRef.current) compositionFileInputRef.current.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
const removeCategory = (category: CatalogCategory) => runAction(
|
||||
() => deleteCatalogCategory(category.id),
|
||||
`Categoria ${category.name} excluida.`
|
||||
@@ -340,7 +377,7 @@ const Registrations = () => {
|
||||
const tabItems: Array<{ key: RegistrationTab; label: string; icon: typeof Package; count: number }> = [
|
||||
{ key: 'products', label: 'Produtos', icon: Package, count: catalog.products.length },
|
||||
{ key: 'categories', label: 'Categorias', icon: Tags, count: catalog.categories.length },
|
||||
{ key: 'references', label: 'Referencia de Consumo', icon: Ruler, count: catalog.consumptionReferences.length }
|
||||
{ key: 'references', label: 'Referência de Consumo', icon: Ruler, count: catalog.consumptionReferences.length }
|
||||
];
|
||||
|
||||
return (
|
||||
@@ -349,18 +386,43 @@ const Registrations = () => {
|
||||
<div>
|
||||
<h1 className="mb-2 text-2xl font-bold text-zinc-900 dark:text-dark-text">Cadastros</h1>
|
||||
<p className="font-medium text-zinc-500 dark:text-dark-muted">
|
||||
Base de produtos, categorias e referencias de consumo usadas pelo corte.
|
||||
Fonte de verdade para produtos, matérias-primas e referências de consumo.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<input
|
||||
ref={compositionFileInputRef}
|
||||
type="file"
|
||||
accept="application/json,.json"
|
||||
className="hidden"
|
||||
onChange={(event) => void importCompositionFile(event.target.files?.[0] || null)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => compositionFileInputRef.current?.click()}
|
||||
disabled={status === 'saving'}
|
||||
className="inline-flex h-10 items-center justify-center gap-2 rounded-xl border border-dark-border bg-dark-card px-4 text-sm font-bold text-dark-text transition-colors hover:border-brand-primary disabled:cursor-not-allowed disabled:opacity-60 cursor-pointer"
|
||||
>
|
||||
<Upload className="h-4 w-4 text-brand-primary" />
|
||||
Importar composições
|
||||
</button>
|
||||
<Link
|
||||
to="/planning-issues"
|
||||
className="inline-flex h-10 items-center justify-center gap-2 rounded-xl border border-dark-border bg-dark-card px-4 text-sm font-bold text-dark-text transition-colors hover:border-brand-primary"
|
||||
>
|
||||
<ClipboardList className="h-4 w-4 text-brand-primary" />
|
||||
Dados Pendentes
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void loadCatalog()}
|
||||
className="inline-flex items-center justify-center gap-2 rounded-xl border border-dark-border bg-dark-card px-4 py-2.5 text-sm font-bold text-dark-text transition-colors hover:border-brand-primary cursor-pointer"
|
||||
className="inline-flex h-10 items-center justify-center gap-2 rounded-xl border border-dark-border bg-dark-card px-4 text-sm font-bold text-dark-text transition-colors hover:border-brand-primary cursor-pointer"
|
||||
>
|
||||
<RefreshCw className="h-4 w-4 text-brand-primary" />
|
||||
Atualizar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-3">
|
||||
{tabItems.map(item => {
|
||||
@@ -654,6 +716,13 @@ const Registrations = () => {
|
||||
<span className="rounded-full border border-dark-border bg-dark-input px-2 py-0.5 text-[10px] font-bold text-dark-muted">
|
||||
{Object.keys(reference.sizeYields || {}).length ? 'Por tamanho' : 'Geral'}
|
||||
</span>
|
||||
<span className={`rounded-full border px-2 py-0.5 text-[10px] font-bold ${
|
||||
reference.source === 'tiny_op' || reference.source === 'tiny_structure'
|
||||
? 'border-sky-400/30 bg-sky-400/10 text-sky-300'
|
||||
: 'border-dark-border bg-dark-input text-dark-muted'
|
||||
}`}>
|
||||
{getReferenceSourceLabel(reference)}
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="mt-1 truncate text-sm font-bold text-dark-text">{reference.productName}</h3>
|
||||
<p className="mt-1 text-xs font-semibold text-dark-muted">
|
||||
@@ -671,10 +740,19 @@ const Registrations = () => {
|
||||
</div>
|
||||
<div className="flex shrink-0 items-start gap-3">
|
||||
<div className="text-right">
|
||||
<div className="text-lg font-bold text-emerald-300">{formatNumber(getAverageYield(reference), 3)} pç/kg</div>
|
||||
<div className="text-[10px] font-bold uppercase tracking-widest text-dark-muted">
|
||||
{Object.keys(reference.sizeYields || {}).length ? 'media' : 'geral'}
|
||||
<div className="text-lg font-bold text-emerald-300">
|
||||
{formatConsumptionPerPiece(reference) || `${formatNumber(getAverageYield(reference), 3)} pç/kg`}
|
||||
</div>
|
||||
<div className="text-[10px] font-bold uppercase tracking-widest text-dark-muted">
|
||||
{reference.consumptionQuantity
|
||||
? 'consumo'
|
||||
: Object.keys(reference.sizeYields || {}).length ? 'media' : 'geral'}
|
||||
</div>
|
||||
{reference.consumptionQuantity && getAverageYield(reference) ? (
|
||||
<div className="mt-1 text-[11px] font-semibold text-dark-muted">
|
||||
{formatNumber(getAverageYield(reference), 3)} pç/kg
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<button type="button" onClick={() => void removeReference(reference)} className="inline-flex h-9 w-9 items-center justify-center rounded-lg border border-dark-border bg-dark-input text-dark-muted transition-colors hover:border-red-400/40 hover:text-red-300 cursor-pointer">
|
||||
<Trash2 className="h-4 w-4" />
|
||||
|
||||
@@ -10,6 +10,7 @@ import type { DateRange, ProductAnalyticsItem, ProductionOrderItem } from '../ty
|
||||
import { exportToCSV, fetchProductAnalytics, fetchProductionOrders } from '../dataService';
|
||||
import { encodeProductGroupKey, parseProductName, sortProductSizes } from '../productParsing';
|
||||
import { formatColorLabel } from '../displayFormatters';
|
||||
import { getPlanningStock } from '../planningStock';
|
||||
|
||||
type ReplenishmentStatus = 'need' | 'covered' | 'no_sales' | 'no_stock';
|
||||
type ReplenishmentFilter = 'all' | ReplenishmentStatus;
|
||||
@@ -174,7 +175,7 @@ const Replenishment = () => {
|
||||
const dailySales = product.quantitySold / rangeDays;
|
||||
const projectedDemand = dailySales * targetCoverageDays;
|
||||
const openProductionQuantity = openProductionByProductId[product.id] || 0;
|
||||
const availableQuantity = product.stock + openProductionQuantity;
|
||||
const availableQuantity = getPlanningStock(product.stock) + openProductionQuantity;
|
||||
const rawNeed = projectedDemand - availableQuantity;
|
||||
const suggestedQuantity = Math.max(0, Math.ceil(rawNeed));
|
||||
const daysOfCover = dailySales > 0 ? availableQuantity / dailySales : null;
|
||||
|
||||
89
src/pages/SellerDetails.tsx
Normal file
89
src/pages/SellerDetails.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Link, useOutletContext, useParams } from 'react-router-dom';
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
import DateRangePicker from '../components/DateRangePicker';
|
||||
import PaginationControls from '../components/PaginationControls';
|
||||
import { fetchSellerDetailsAnalytics } from '../dataService';
|
||||
import { formatDisplayName, removeTrailingSellerId } from '../displayFormatters';
|
||||
import type { DateRange, SellerDetailsAnalytics } from '../types';
|
||||
|
||||
const formatNumber = (value: number) => new Intl.NumberFormat('pt-BR', { maximumFractionDigits: 0 }).format(value);
|
||||
const formatCurrency = (value: number) => new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(value);
|
||||
|
||||
const SellerDetailsSkeleton = () => (
|
||||
<div className="space-y-6" aria-label="Carregando detalhes do vendedor">
|
||||
<div className="flex flex-col gap-4 xl:flex-row xl:items-start xl:justify-between">
|
||||
<div className="space-y-3"><div className="skeleton h-4 w-24" /><div className="skeleton h-7 w-64" /><div className="skeleton h-4 w-96 max-w-full" /></div>
|
||||
<div className="skeleton h-11 w-56" />
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
||||
{[1, 2, 3, 4].map(item => <div key={item} className="rounded-2xl border border-dark-border bg-dark-card p-5 shadow-sm"><div className="skeleton h-3 w-20" /><div className="skeleton mt-3 h-7 w-32" /></div>)}
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-6 xl:grid-cols-[1.3fr_0.7fr]">
|
||||
<div className="rounded-2xl border border-dark-border bg-dark-card p-5 shadow-sm"><div className="skeleton h-5 w-40" /><div className="skeleton mt-2 h-4 w-48" /><div className="mt-6 space-y-4">{[1, 2, 3, 4, 5, 6].map(item => <div key={item} className="grid grid-cols-[52px_1fr_72px] items-center gap-3"><div className="skeleton h-3" /><div className="skeleton h-2" /><div className="skeleton h-3" /></div>)}</div></div>
|
||||
<div className="rounded-2xl border border-dark-border bg-dark-card p-5 shadow-sm"><div className="skeleton h-5 w-20" /><div className="skeleton mt-2 h-4 w-40" /><div className="mt-6 space-y-3">{[1, 2, 3].map(item => <div key={item} className="rounded-xl border border-dark-border p-3"><div className="skeleton h-4 w-28" /><div className="skeleton mt-2 h-3 w-40" /></div>)}</div></div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-6 xl:grid-cols-2">{[1, 2].map(item => <div key={item} className="overflow-hidden rounded-2xl border border-dark-border bg-dark-card shadow-sm"><div className="border-b border-dark-border p-5"><div className="skeleton h-5 w-40" /></div><div className="space-y-0">{[1, 2, 3, 4].map(row => <div key={row} className="flex items-center justify-between gap-4 border-b border-dark-border p-4 last:border-b-0"><div><div className="skeleton h-4 w-48" /><div className="skeleton mt-2 h-3 w-20" /></div><div className="skeleton h-4 w-20" /></div>)}</div></div>)}</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const SellerDetails = () => {
|
||||
const { sellerId = '' } = useParams<{ sellerId: string }>();
|
||||
const { dateRange, setDateRange } = useOutletContext<{ dateRange: DateRange; setDateRange: (range: DateRange) => void }>();
|
||||
const [details, setDetails] = useState<SellerDetailsAnalytics | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [trendPage, setTrendPage] = useState(1);
|
||||
const trendPageSize = 14;
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
const load = async () => {
|
||||
setIsLoading(true);
|
||||
const result = await fetchSellerDetailsAnalytics(sellerId, dateRange);
|
||||
if (isMounted) { setDetails(result); setIsLoading(false); }
|
||||
};
|
||||
void load();
|
||||
return () => { isMounted = false; };
|
||||
}, [dateRange, sellerId]);
|
||||
|
||||
const maxRevenue = useMemo(() => Math.max(...(details?.chartData.map(item => item.revenue) || [1]), 1), [details]);
|
||||
const trendTotalPages = Math.ceil((details?.chartData.length || 0) / trendPageSize);
|
||||
const safeTrendPage = Math.min(trendPage, trendTotalPages || 1);
|
||||
const trendStartIndex = (safeTrendPage - 1) * trendPageSize;
|
||||
const paginatedTrend = details?.chartData.slice(trendStartIndex, trendStartIndex + trendPageSize) || [];
|
||||
if (isLoading && !details) return <SellerDetailsSkeleton />;
|
||||
if (!details) return <div className="rounded-2xl border border-dark-border bg-dark-card p-10 text-center"><p className="font-bold text-dark-text">Vendedor não encontrado no período.</p><Link to="/sellers" className="mt-4 inline-flex text-sm font-bold text-brand-primary">Voltar para vendedores</Link></div>;
|
||||
const { seller } = details;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col gap-4 xl:flex-row xl:items-start xl:justify-between">
|
||||
<div>
|
||||
<Link to="/sellers" className="inline-flex items-center gap-2 text-sm font-bold text-dark-muted transition-colors hover:text-dark-text"><ArrowLeft className="h-4 w-4" /> Vendedores</Link>
|
||||
<h1 className="mt-4 text-2xl font-bold text-dark-text">{formatDisplayName(removeTrailingSellerId(seller.name))}</h1>
|
||||
<p className="mt-2 font-medium text-dark-muted">Carteira, produtos e resultado comercial no período selecionado.</p>
|
||||
</div>
|
||||
<DateRangePicker dateRange={dateRange} onChange={range => { setDateRange(range); setTrendPage(1); }} />
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
||||
{[['Receita', formatCurrency(seller.revenue), 'text-brand-primary'], ['Pedidos', formatNumber(seller.orderCount), 'text-dark-text'], ['Clientes', formatNumber(seller.customerCount), 'text-dark-text'], ['Ticket médio', formatCurrency(seller.averageTicket), 'text-dark-text']].map(([label, value, color]) => <div key={label} className="rounded-2xl border border-dark-border bg-dark-card p-5 shadow-sm"><p className="text-xs font-bold uppercase tracking-widest text-dark-muted">{label}</p><p className={`mt-2 text-2xl font-bold ${color}`}>{value}</p></div>)}
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-6 xl:grid-cols-[1.3fr_0.7fr]">
|
||||
<section className="overflow-hidden rounded-2xl border border-dark-border bg-dark-card shadow-sm">
|
||||
<div className="p-5"><h2 className="text-base font-bold text-dark-text">Evolução da receita</h2><p className="mt-1 text-sm font-semibold text-dark-muted">Vendas por dia no período.</p></div>
|
||||
{details.chartData.length ? <>
|
||||
<div className="space-y-3 px-5 pb-5">{paginatedTrend.map(point => <div key={point.date} className="grid grid-cols-[52px_1fr_auto] items-center gap-3 text-xs sm:grid-cols-[76px_1fr_auto]"><span className="font-semibold text-dark-muted">{new Date(`${point.date}T12:00:00`).toLocaleDateString('pt-BR', { day: '2-digit', month: '2-digit' })}</span><div className="h-2 overflow-hidden rounded-full bg-dark-input"><div className="h-full rounded-full bg-brand-primary" style={{ width: `${(point.revenue / maxRevenue) * 100}%` }} /></div><span className="font-bold text-dark-text">{formatCurrency(point.revenue)}</span></div>)}</div>
|
||||
<PaginationControls totalItems={details.chartData.length} currentPage={safeTrendPage} totalPages={trendTotalPages} pageSize={trendPageSize} pageSizeOptions={[14]} itemLabel="dias" pageSizeLabel="dias por página" startIndex={trendStartIndex} endIndex={Math.min(trendStartIndex + trendPageSize, details.chartData.length)} onPageChange={setTrendPage} onPageSizeChange={() => setTrendPage(1)} className="border-t border-dark-border px-4 py-3" />
|
||||
</> : <p className="px-5 py-10 text-center text-sm font-semibold text-dark-muted">Sem vendas no período.</p>}
|
||||
</section>
|
||||
<section className="rounded-2xl border border-dark-border bg-dark-card p-5 shadow-sm"><h2 className="text-base font-bold text-dark-text">Canais</h2><p className="mt-1 text-sm font-semibold text-dark-muted">Receita por marketplace.</p><div className="mt-5 space-y-3">{details.marketplaces.map(item => <div key={item.name} className="rounded-xl border border-dark-border bg-dark-input/50 p-3"><p className="text-sm font-bold text-dark-text">{item.name}</p><p className="mt-1 text-xs font-semibold text-brand-primary">{formatCurrency(item.revenue)} · {formatNumber(item.orderCount)} pedidos</p></div>)}</div></section>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-6 xl:grid-cols-2">
|
||||
<section className="overflow-hidden rounded-2xl border border-dark-border bg-dark-card shadow-sm"><div className="border-b border-dark-border p-5"><h2 className="text-base font-bold text-dark-text">Produtos mais vendidos</h2></div><div className="divide-y divide-dark-border">{details.topProducts.map(product => <Link key={product.id} to={`/products/${product.id}`} className="flex items-center justify-between gap-4 p-4 transition-colors hover:bg-dark-input/35"><div className="min-w-0"><p className="truncate text-sm font-bold text-dark-text">{product.name}</p><p className="mt-1 text-xs font-semibold text-dark-muted">{formatNumber(product.quantitySold)} un. vendidas</p></div><span className="shrink-0 text-sm font-bold text-brand-primary">{formatCurrency(product.revenue)}</span></Link>)}</div></section>
|
||||
<section className="overflow-hidden rounded-2xl border border-dark-border bg-dark-card shadow-sm"><div className="border-b border-dark-border p-5"><h2 className="text-base font-bold text-dark-text">Carteira no período</h2></div><div className="divide-y divide-dark-border">{details.topClients.map(client => <Link key={client.clientToken} to={`/clients/${client.clientToken}`} className="flex items-center justify-between gap-4 p-4 transition-colors hover:bg-dark-input/35"><div className="min-w-0"><p className="truncate text-sm font-bold text-dark-text">{client.name}</p><p className="mt-1 text-xs font-semibold text-dark-muted">{formatNumber(client.orderCount)} pedidos</p></div><span className="shrink-0 text-sm font-bold text-brand-primary">{formatCurrency(client.totalSpent)}</span></Link>)}</div></section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SellerDetails;
|
||||
150
src/pages/Sellers.tsx
Normal file
150
src/pages/Sellers.tsx
Normal file
@@ -0,0 +1,150 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Link, useOutletContext } from 'react-router-dom';
|
||||
import { BriefcaseBusiness, Eye, Search } from 'lucide-react';
|
||||
import DateRangePicker from '../components/DateRangePicker';
|
||||
import PaginationControls from '../components/PaginationControls';
|
||||
import RefreshStatus from '../components/RefreshStatus';
|
||||
import { fetchSellerAnalytics } from '../dataService';
|
||||
import { formatDisplayName, removeTrailingSellerId } from '../displayFormatters';
|
||||
import type { DateRange, SellerAnalyticsItem } from '../types';
|
||||
|
||||
const formatNumber = (value: number) => new Intl.NumberFormat('pt-BR', { maximumFractionDigits: 0 }).format(value);
|
||||
const formatCurrency = (value: number) => new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(value);
|
||||
|
||||
const SellersTableSkeleton = () => (
|
||||
<div className="overflow-x-auto">
|
||||
<div className="min-w-[980px]">
|
||||
<div className="grid grid-cols-[90px_1.5fr_repeat(5,120px)_80px] gap-5 border-b border-dark-border bg-dark-header px-6 py-4">
|
||||
{[1, 2, 3, 4, 5, 6, 7, 8].map(item => <div key={item} className="skeleton h-3" />)}
|
||||
</div>
|
||||
<div className="divide-y divide-dark-border">
|
||||
{[1, 2, 3, 4, 5, 6].map(row => <div key={row} className="grid grid-cols-[90px_1.5fr_repeat(5,120px)_80px] items-center gap-5 px-6 py-3"><div className="skeleton h-7 w-7 rounded-full" /><div><div className="skeleton h-4 w-40" /><div className="skeleton mt-2 h-3 w-20" /></div>{[1, 2, 3, 4, 5].map(column => <div key={column} className="skeleton h-4" />)}<div className="skeleton h-8 w-8 rounded-lg" /></div>)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const Sellers = () => {
|
||||
const { dateRange, setDateRange } = useOutletContext<{ dateRange: DateRange; setDateRange: (range: DateRange) => void }>();
|
||||
const [sellers, setSellers] = useState<SellerAnalyticsItem[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [search, setSearch] = useState('');
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [itemsPerPage, setItemsPerPage] = useState(10);
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
const load = async () => {
|
||||
setIsLoading(true);
|
||||
const rows = await fetchSellerAnalytics(dateRange);
|
||||
if (isMounted) {
|
||||
setSellers(rows);
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
void load();
|
||||
return () => { isMounted = false; };
|
||||
}, [dateRange]);
|
||||
|
||||
const visibleSellers = useMemo(() => {
|
||||
const term = search.trim().toLocaleLowerCase('pt-BR');
|
||||
if (!term) return sellers;
|
||||
return sellers.filter(seller => `${seller.name} ${seller.id}`.toLocaleLowerCase('pt-BR').includes(term));
|
||||
}, [search, sellers]);
|
||||
const totalRevenue = sellers.reduce((sum, seller) => sum + seller.revenue, 0);
|
||||
const totalOrders = sellers.reduce((sum, seller) => sum + seller.orderCount, 0);
|
||||
const totalCustomers = sellers.reduce((sum, seller) => sum + seller.customerCount, 0);
|
||||
const topSeller = sellers[0];
|
||||
const totalPages = Math.ceil(visibleSellers.length / itemsPerPage);
|
||||
const safeCurrentPage = Math.min(currentPage, totalPages || 1);
|
||||
const startIndex = (safeCurrentPage - 1) * itemsPerPage;
|
||||
const paginatedSellers = visibleSellers.slice(startIndex, startIndex + itemsPerPage);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col gap-4 xl:flex-row xl:items-center xl:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-dark-text">Vendedores</h1>
|
||||
<p className="mt-2 font-medium text-dark-muted">Performance comercial, carteira e produtos vendidos por responsável.</p>
|
||||
</div>
|
||||
<DateRangePicker dateRange={dateRange} onChange={setDateRange} />
|
||||
</div>
|
||||
|
||||
<RefreshStatus isRefreshing={isLoading && sellers.length > 0} />
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<div className="rounded-2xl border border-dark-border bg-dark-card p-5 shadow-sm">
|
||||
<p className="text-xs font-bold uppercase tracking-widest text-dark-muted">Receita da equipe</p>
|
||||
{isLoading && !sellers.length ? <div className="skeleton mt-3 h-7 w-36" /> : <p className="mt-2 text-2xl font-bold text-brand-primary">{formatCurrency(totalRevenue)}</p>}
|
||||
</div>
|
||||
<div className="rounded-2xl border border-dark-border bg-dark-card p-5 shadow-sm">
|
||||
<p className="text-xs font-bold uppercase tracking-widest text-dark-muted">Pedidos</p>
|
||||
{isLoading && !sellers.length ? <div className="skeleton mt-3 h-8 w-20" /> : <p className="mt-2 text-3xl font-bold text-dark-text">{formatNumber(totalOrders)}</p>}
|
||||
</div>
|
||||
<div className="rounded-2xl border border-dark-border bg-dark-card p-5 shadow-sm">
|
||||
<p className="text-xs font-bold uppercase tracking-widest text-dark-muted">Clientes atendidos</p>
|
||||
{isLoading && !sellers.length ? <div className="skeleton mt-3 h-8 w-20" /> : <p className="mt-2 text-3xl font-bold text-dark-text">{formatNumber(totalCustomers)}</p>}
|
||||
</div>
|
||||
<div className="rounded-2xl border border-dark-border bg-dark-card p-5 shadow-sm">
|
||||
<p className="text-xs font-bold uppercase tracking-widest text-dark-muted">Maior receita</p>
|
||||
{isLoading && !sellers.length ? <><div className="skeleton mt-3 h-5 w-40" /><div className="skeleton mt-2 h-3 w-24" /></> : <><p className="mt-2 truncate text-lg font-bold text-dark-text">{topSeller ? formatDisplayName(removeTrailingSellerId(topSeller.name)) : '—'}</p><p className="mt-1 text-xs font-semibold text-dark-muted">{topSeller ? formatCurrency(topSeller.revenue) : 'Sem vendas no período'}</p></>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-dark-border bg-dark-card shadow-sm">
|
||||
<div className="flex flex-col gap-4 border-b border-dark-border p-4 sm:p-5 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div>
|
||||
<h2 className="text-base font-bold text-dark-text">Equipe comercial</h2>
|
||||
<p className="mt-1 text-sm font-semibold text-dark-muted">Selecione um vendedor para ver carteira, produtos e evolução.</p>
|
||||
</div>
|
||||
<label className="relative block w-full lg:max-w-sm">
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-dark-muted" />
|
||||
<input value={search} onChange={event => { setSearch(event.target.value); setCurrentPage(1); }} placeholder="Buscar vendedor..." className="h-11 w-full rounded-xl border border-dark-border bg-dark-input pl-10 pr-3 text-sm font-semibold text-dark-text outline-none placeholder:text-dark-muted focus:border-brand-primary" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{isLoading && !sellers.length ? (
|
||||
<SellersTableSkeleton />
|
||||
) : visibleSellers.length ? (
|
||||
<>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[980px] text-left text-sm">
|
||||
<thead className="border-b border-dark-border bg-dark-header text-dark-muted">
|
||||
<tr>
|
||||
<th className="px-6 py-4 text-[10px] font-bold uppercase tracking-wider">Posição</th>
|
||||
<th className="px-6 py-4 text-[10px] font-bold uppercase tracking-wider">Nome do vendedor</th>
|
||||
<th className="px-6 py-4 text-[10px] font-bold uppercase tracking-wider">Receita</th>
|
||||
<th className="px-6 py-4 text-[10px] font-bold uppercase tracking-wider">Pedidos</th>
|
||||
<th className="px-6 py-4 text-[10px] font-bold uppercase tracking-wider">Clientes</th>
|
||||
<th className="px-6 py-4 text-[10px] font-bold uppercase tracking-wider">Ticket médio</th>
|
||||
<th className="px-6 py-4 text-[10px] font-bold uppercase tracking-wider">Produtos vendidos</th>
|
||||
<th className="px-6 py-4 text-right text-[10px] font-bold uppercase tracking-wider">Ações</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-dark-border">
|
||||
{paginatedSellers.map((seller, index) => (
|
||||
<tr key={seller.id} className="group transition-colors hover:bg-dark-input/50">
|
||||
<td className="px-6 py-2.5"><span className="inline-flex h-7 w-7 items-center justify-center rounded-full bg-dark-border text-xs font-bold text-dark-muted">{startIndex + index + 1}</span></td>
|
||||
<td className="px-6 py-2.5"><p className="font-semibold text-dark-text">{formatDisplayName(removeTrailingSellerId(seller.name))}</p><p className="mt-1 text-[10px] font-semibold text-dark-muted">{seller.id.startsWith('name:') ? 'Sem ID Tiny' : `ID ${seller.id}`}</p></td>
|
||||
<td className="px-6 py-2.5 font-bold text-brand-primary">{formatCurrency(seller.revenue)}</td>
|
||||
<td className="px-6 py-2.5 font-semibold text-dark-text">{formatNumber(seller.orderCount)}</td>
|
||||
<td className="px-6 py-2.5 font-semibold text-dark-text">{formatNumber(seller.customerCount)}</td>
|
||||
<td className="px-6 py-2.5 font-semibold text-dark-text">{formatCurrency(seller.averageTicket)}</td>
|
||||
<td className="px-6 py-2.5 text-xs font-medium text-dark-muted">{formatNumber(seller.quantitySold)} un. ({formatNumber(seller.orderCount)} {seller.orderCount === 1 ? 'pedido' : 'pedidos'})</td>
|
||||
<td className="px-6 py-2.5 text-right"><Link to={`/sellers/${encodeURIComponent(seller.id)}`} className="inline-flex h-8 w-8 items-center justify-center rounded-lg bg-brand-primary/10 text-brand-primary transition-opacity hover:opacity-80" title={`Ver detalhes de ${seller.name}`} aria-label={`Ver detalhes de ${seller.name}`}><Eye className="h-3.5 w-3.5" /></Link></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<PaginationControls totalItems={visibleSellers.length} currentPage={safeCurrentPage} totalPages={totalPages} pageSize={itemsPerPage} pageSizeOptions={[10, 20, 50]} itemLabel="vendedores" pageSizeLabel="itens por página" startIndex={startIndex} endIndex={Math.min(startIndex + itemsPerPage, visibleSellers.length)} onPageChange={setCurrentPage} onPageSizeChange={pageSize => { setItemsPerPage(pageSize); setCurrentPage(1); }} />
|
||||
</>
|
||||
) : (
|
||||
<div className="flex flex-col items-center px-6 py-16 text-center"><BriefcaseBusiness className="h-10 w-10 text-dark-muted" /><p className="mt-4 text-sm font-bold text-dark-text">Nenhum vendedor encontrado.</p><p className="mt-1 text-sm font-semibold text-dark-muted">Ajuste a busca ou o período selecionado.</p></div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Sellers;
|
||||
File diff suppressed because it is too large
Load Diff
1
src/planningStock.ts
Normal file
1
src/planningStock.ts
Normal file
@@ -0,0 +1 @@
|
||||
export const getPlanningStock = (stock: number) => Math.max(0, stock);
|
||||
177
src/productClassification.ts
Normal file
177
src/productClassification.ts
Normal file
@@ -0,0 +1,177 @@
|
||||
import { normalizeProductText } from './productParsing.ts';
|
||||
|
||||
export type ProductTypeKey =
|
||||
| 'finished_apparel'
|
||||
| 'finished_accessory'
|
||||
| 'raw_material'
|
||||
| 'packaging'
|
||||
| 'dtf_input'
|
||||
| 'dtf_service'
|
||||
| 'kit_bundle'
|
||||
| 'service'
|
||||
| 'machine_part'
|
||||
| 'equipment'
|
||||
| 'ignore_from_planning'
|
||||
| 'unknown';
|
||||
|
||||
export type ProductPlanningMode = 'cutting' | 'unit_replenishment' | 'material' | 'service' | 'ignore' | 'review';
|
||||
|
||||
export type ProductTypeConfig = {
|
||||
key: ProductTypeKey;
|
||||
label: string;
|
||||
description: string;
|
||||
planningMode: ProductPlanningMode;
|
||||
badgeClassName: string;
|
||||
};
|
||||
|
||||
export const productTypeConfigs: Record<ProductTypeKey, ProductTypeConfig> = {
|
||||
finished_apparel: {
|
||||
key: 'finished_apparel',
|
||||
label: 'Vestuário',
|
||||
description: 'Produto acabado que pode entrar em corte/reposição por SKU.',
|
||||
planningMode: 'cutting',
|
||||
badgeClassName: 'border-sky-400/30 bg-sky-400/10 text-sky-300'
|
||||
},
|
||||
finished_accessory: {
|
||||
key: 'finished_accessory',
|
||||
label: 'Acessório',
|
||||
description: 'Produto acabado sem regra de malha principal.',
|
||||
planningMode: 'unit_replenishment',
|
||||
badgeClassName: 'border-cyan-400/30 bg-cyan-400/10 text-cyan-300'
|
||||
},
|
||||
raw_material: {
|
||||
key: 'raw_material',
|
||||
label: 'Matéria-prima',
|
||||
description: 'Entrada de produção, como malha, ribana, tecido, fio ou resíduo.',
|
||||
planningMode: 'material',
|
||||
badgeClassName: 'border-emerald-400/30 bg-emerald-400/10 text-emerald-300'
|
||||
},
|
||||
packaging: {
|
||||
key: 'packaging',
|
||||
label: 'Embalagem',
|
||||
description: 'Insumo de embalagem controlado por unidade/milheiro.',
|
||||
planningMode: 'unit_replenishment',
|
||||
badgeClassName: 'border-amber-400/30 bg-amber-400/10 text-amber-300'
|
||||
},
|
||||
dtf_input: {
|
||||
key: 'dtf_input',
|
||||
label: 'Insumo DTF',
|
||||
description: 'Insumo para DTF, como tinta, filme, poliamida ou fluido.',
|
||||
planningMode: 'material',
|
||||
badgeClassName: 'border-fuchsia-400/30 bg-fuchsia-400/10 text-fuchsia-300'
|
||||
},
|
||||
dtf_service: {
|
||||
key: 'dtf_service',
|
||||
label: 'Serviço DTF',
|
||||
description: 'Impressão, estampa ou personalização vendida como serviço/produto customizado.',
|
||||
planningMode: 'service',
|
||||
badgeClassName: 'border-purple-400/30 bg-purple-400/10 text-purple-300'
|
||||
},
|
||||
kit_bundle: {
|
||||
key: 'kit_bundle',
|
||||
label: 'Kit',
|
||||
description: 'Bundle marketplace composto por outros SKUs.',
|
||||
planningMode: 'review',
|
||||
badgeClassName: 'border-indigo-400/30 bg-indigo-400/10 text-indigo-300'
|
||||
},
|
||||
service: {
|
||||
key: 'service',
|
||||
label: 'Serviço',
|
||||
description: 'Frete, transporte, tecelagem, tinturaria ou serviço técnico.',
|
||||
planningMode: 'service',
|
||||
badgeClassName: 'border-zinc-400/30 bg-zinc-400/10 text-zinc-300'
|
||||
},
|
||||
machine_part: {
|
||||
key: 'machine_part',
|
||||
label: 'Peça máquina',
|
||||
description: 'Peça, limpeza ou manutenção de máquina.',
|
||||
planningMode: 'unit_replenishment',
|
||||
badgeClassName: 'border-orange-400/30 bg-orange-400/10 text-orange-300'
|
||||
},
|
||||
equipment: {
|
||||
key: 'equipment',
|
||||
label: 'Equipamento',
|
||||
description: 'Máquina ou equipamento permanente.',
|
||||
planningMode: 'ignore',
|
||||
badgeClassName: 'border-slate-400/30 bg-slate-400/10 text-slate-300'
|
||||
},
|
||||
ignore_from_planning: {
|
||||
key: 'ignore_from_planning',
|
||||
label: 'Ignorar',
|
||||
description: 'Item que não deve dirigir corte, compra ou reposição.',
|
||||
planningMode: 'ignore',
|
||||
badgeClassName: 'border-dark-border bg-dark-input text-dark-muted'
|
||||
},
|
||||
unknown: {
|
||||
key: 'unknown',
|
||||
label: 'Revisar',
|
||||
description: 'Tipo não identificado automaticamente.',
|
||||
planningMode: 'review',
|
||||
badgeClassName: 'border-red-400/30 bg-red-400/10 text-red-300'
|
||||
}
|
||||
};
|
||||
|
||||
const has = (value: string, pattern: RegExp) => pattern.test(value);
|
||||
|
||||
export const classifyProductType = (name: string): ProductTypeKey => {
|
||||
const normalizedName = normalizeProductText(name)
|
||||
.normalize('NFD')
|
||||
.replace(/\p{Diacritic}/gu, '')
|
||||
.toUpperCase();
|
||||
|
||||
if (!normalizedName) return 'unknown';
|
||||
|
||||
if (has(normalizedName, /\bSALDO ESTOQUE\b/)) return 'ignore_from_planning';
|
||||
if (has(normalizedName, /^(?:\d+\s+)?(?:MALHA|RIBANA)\b/)) return 'raw_material';
|
||||
if (has(normalizedName, /\b(?:RETALHO|RESIDUO)\s+(?:DE\s+)?(?:MALHA|MOLETOM)\b/)) return 'raw_material';
|
||||
if (has(normalizedName, /\bFIO\b.*\bMALHARIA\b/)) return 'raw_material';
|
||||
if (has(normalizedName, /\b(?:LINHA|FIO) PARA COSTURA\b/)) return 'raw_material';
|
||||
if (has(normalizedName, /\bATACADOR\b/)) return 'raw_material';
|
||||
if (has(normalizedName, /\bILHOS\b/)) return 'raw_material';
|
||||
if (has(normalizedName, /ETIQUETA|TAG|FITA/)) return 'raw_material';
|
||||
if (has(normalizedName, /\bCREDITO\b/)) return 'ignore_from_planning';
|
||||
if (has(normalizedName, /\bKIT\b/)) return 'kit_bundle';
|
||||
if (has(normalizedName, /TRANSPARENTE PP|SACO DE SEGURANCA|MILHEIRO|SACOLA|EMBALAGEM/)) return 'packaging';
|
||||
if (has(normalizedName, /PRENSA|MAQUINA|OVERLOCK|OVERLOK|GALONEIRA|PRATELEIRA/)) return 'equipment';
|
||||
if (has(normalizedName, /FRETE|SERVICO|TRANSPORTE|MOTOTAXI|TECELAGEM|TINTURARIA|MAO DE OBRA/)) return 'service';
|
||||
if (has(normalizedName, /DUMPER|CABO FLAT|WIPPER|PRIMER|FLUIDO DE LIMPEZA|MISTURADOR|CABECA I3200|CABECA DE IMPRESSAO|SENSOR INFRAVERMELHO|CAPSULA FILTRO|PECA DE MAQUINA/)) return 'machine_part';
|
||||
if (has(normalizedName, /TINTA DTF|FILME DTF|POLIAMIDA.*DTF|DTF ROLO|PO PARA DTF|BOMBA DE TINTA/)) return 'dtf_input';
|
||||
if (has(normalizedName, /IMPRESSAO DTF|IMPRESSAO UV|CORRECAO IMPRESSAO|ESTAMPA|PERSONALIZACAO/)) return 'dtf_service';
|
||||
if (has(normalizedName, /MALHA|RIBANA|FIO|TECIDO|RESIDUO|RETALHO|PIMA|ALGODAO/)) {
|
||||
if (!has(normalizedName, /CAMISETA|MOLETOM|REGATA|BONE|CHINELO|OVERSIZE|OVER SIZE/)) {
|
||||
return 'raw_material';
|
||||
}
|
||||
}
|
||||
if (has(normalizedName, /BONE|TRUCKER|\bCAP\b|CHINELO/)) return 'finished_accessory';
|
||||
if (has(normalizedName, /CAMISETA|MOLETOM|CANGURU|REGATA|INFANTIL|OVER SIZE|OVERSIZE|PROMOCIONAL|VESTUARIO/)) return 'finished_apparel';
|
||||
if (has(normalizedName, /ETIQUETA|TAG|FITA|LINHA PARA COSTURA|COSTURA/)) return 'raw_material';
|
||||
|
||||
return 'unknown';
|
||||
};
|
||||
|
||||
export const getProductTypeConfig = (type: ProductTypeKey) => productTypeConfigs[type] || productTypeConfigs.unknown;
|
||||
|
||||
export const productTypeOptions = Object.values(productTypeConfigs)
|
||||
.filter(config => config.key !== 'ignore_from_planning')
|
||||
.map(config => ({ value: config.key, label: config.label }));
|
||||
|
||||
export const editableProductTypeOptions = Object.values(productTypeConfigs)
|
||||
.map(config => ({ value: config.key, label: config.label, description: config.description }));
|
||||
|
||||
export const resolveProductType = (
|
||||
name: string,
|
||||
override?: { productType?: ProductTypeKey | '' } | null
|
||||
): ProductTypeKey => {
|
||||
return override?.productType || classifyProductType(name);
|
||||
};
|
||||
|
||||
export const getDominantProductType = <T extends { productType: ProductTypeKey; quantitySold: number }>(rows: T[]) => {
|
||||
if (!rows.length) return 'unknown';
|
||||
|
||||
const totals = rows.reduce<Record<string, number>>((acc, row) => {
|
||||
acc[row.productType] = (acc[row.productType] || 0) + Math.max(row.quantitySold, 1);
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
return (Object.entries(totals).sort((a, b) => b[1] - a[1])[0]?.[0] || 'unknown') as ProductTypeKey;
|
||||
};
|
||||
@@ -1,8 +1,54 @@
|
||||
const sizeOrder = ['2', '4', '6', '8', '10', '12', '14', '16', 'PP', 'P', 'M', 'G', 'GG', 'XG', 'G1', 'G2', 'G3', 'G4', 'G5'];
|
||||
const sizeSet = new Set(sizeOrder);
|
||||
const knownColors = [
|
||||
'BRANCO + PRETO',
|
||||
'CINZA/PRETO',
|
||||
'VERDE BANDEIRA',
|
||||
'VERDE MILITAR',
|
||||
'AZUL MARINHO',
|
||||
'CINZA GRAFITE',
|
||||
'AMARELO',
|
||||
'BRANCO',
|
||||
'GRAFITE',
|
||||
'MARINHO',
|
||||
'VERMELHO',
|
||||
'BORDO',
|
||||
'CAFE',
|
||||
'CAQUI',
|
||||
'CHUMBO',
|
||||
'CINZA',
|
||||
'MARROM',
|
||||
'PEROLA',
|
||||
'PRETO',
|
||||
'ROYAL',
|
||||
'ROSA',
|
||||
'ROXO',
|
||||
'VERDE',
|
||||
'AZUL',
|
||||
'BEGE'
|
||||
].sort((a, b) => b.length - a.length);
|
||||
|
||||
export const normalizeProductText = (value: string) => value.replace(/\s+/g, ' ').trim();
|
||||
|
||||
const normalizeForMatch = (value: string) => normalizeProductText(value)
|
||||
.normalize('NFD')
|
||||
.replace(/\p{Diacritic}/gu, '')
|
||||
.toUpperCase();
|
||||
|
||||
const escapeRegex = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
|
||||
const toFlexiblePattern = (value: string) => escapeRegex(value).replace(/\s+/g, '\\s+');
|
||||
|
||||
const findLeadingColor = (value: string) => {
|
||||
const normalizedValue = normalizeForMatch(value);
|
||||
return knownColors.find(color => normalizedValue === color || normalizedValue.startsWith(`${color} `)) || '';
|
||||
};
|
||||
|
||||
const findTrailingColor = (value: string) => {
|
||||
const normalizedValue = normalizeForMatch(value);
|
||||
return knownColors.find(color => normalizedValue === color || normalizedValue.endsWith(` ${color}`) || normalizedValue.endsWith(`- ${color}`)) || '';
|
||||
};
|
||||
|
||||
export const encodeProductGroupKey = (value: string) => {
|
||||
const normalizedValue = normalizeProductText(value);
|
||||
const bytes = new TextEncoder().encode(normalizedValue);
|
||||
@@ -34,16 +80,51 @@ export const parseProductName = (name: string) => {
|
||||
const explicitSizeMatch = cleanName.match(/\bTAMANHO\s*-?\s*([A-Z0-9]+)\b/i);
|
||||
const trailingTokenMatch = cleanName.match(/(?:\s+-\s+|\s)([A-Z0-9]+)$/i);
|
||||
const trailingToken = trailingTokenMatch?.[1]?.toUpperCase() || '';
|
||||
const size = (explicitSizeMatch?.[1] || (sizeSet.has(trailingToken) ? trailingToken : '')).toUpperCase();
|
||||
let size = (explicitSizeMatch?.[1] || (sizeSet.has(trailingToken) ? trailingToken : '')).toUpperCase();
|
||||
|
||||
const colorMatch = cleanName.match(/\bCOR\s+(.+?)(?:\s+TAMANHO|\s+-\s+[A-Z0-9]+$|$)/i);
|
||||
const color = normalizeProductText(colorMatch?.[1] || '');
|
||||
let color = normalizeProductText(findLeadingColor(colorMatch?.[1] || '') || colorMatch?.[1] || '');
|
||||
|
||||
if (!color && size) {
|
||||
const nameBeforeSize = cleanName.replace(new RegExp(`(?:\\s+-\\s+|\\s)${escapeRegex(size)}$`, 'i'), '');
|
||||
color = findTrailingColor(nameBeforeSize);
|
||||
}
|
||||
|
||||
if (!color) {
|
||||
const trailingColor = findTrailingColor(cleanName);
|
||||
if (trailingColor) {
|
||||
const normalizedName = normalizeForMatch(cleanName);
|
||||
const normalizedPrefix = normalizeProductText(normalizedName.replace(new RegExp(`${escapeRegex(trailingColor)}$`), ''));
|
||||
const tokenBeforeColor = normalizedPrefix.match(/([A-Z0-9]+)$/)?.[1] || '';
|
||||
if (sizeSet.has(tokenBeforeColor)) {
|
||||
color = trailingColor;
|
||||
size = tokenBeforeColor;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let baseName = cleanName
|
||||
.replace(/\bCOR\s+.+?(?:\s+TAMANHO\s*-?\s*[A-Z0-9]+|\s+-\s+[A-Z0-9]+$|$)/i, '')
|
||||
.replace(/\bTAMANHO\s*-?\s*[A-Z0-9]+\b/i, '')
|
||||
.replace(/\s+-\s*[A-Z0-9]+$/i, '');
|
||||
|
||||
if (color && size) {
|
||||
const colorPattern = toFlexiblePattern(color);
|
||||
const sizePattern = escapeRegex(size);
|
||||
baseName = baseName
|
||||
.replace(new RegExp(`\\s+-\\s*${colorPattern}\\s+${sizePattern}$`, 'i'), '')
|
||||
.replace(new RegExp(`\\s+${colorPattern}\\s+-\\s*${sizePattern}$`, 'i'), '')
|
||||
.replace(new RegExp(`\\s+${sizePattern}\\s+${colorPattern}$`, 'i'), '')
|
||||
.replace(new RegExp(`\\s+${colorPattern}\\s+${sizePattern}$`, 'i'), '');
|
||||
}
|
||||
|
||||
if (color) {
|
||||
const colorPattern = toFlexiblePattern(color);
|
||||
baseName = baseName
|
||||
.replace(new RegExp(`\\s+-\\s*${colorPattern}$`, 'i'), '')
|
||||
.replace(new RegExp(`\\s+${colorPattern}$`, 'i'), '');
|
||||
}
|
||||
|
||||
baseName = normalizeProductText(baseName.replace(/\s+-\s*$/g, ''));
|
||||
|
||||
return {
|
||||
|
||||
131
src/types.ts
131
src/types.ts
@@ -1,3 +1,5 @@
|
||||
import type { ProductTypeKey } from './productClassification';
|
||||
|
||||
export interface OrderData {
|
||||
Nome_Cliente: string;
|
||||
Data_Pedido: string;
|
||||
@@ -32,6 +34,26 @@ export interface ProductionOrderMarker {
|
||||
color?: string | null;
|
||||
}
|
||||
|
||||
export interface ProductionOrderComponent {
|
||||
id: number;
|
||||
componentTinyId: string;
|
||||
componentSku: string;
|
||||
componentName: string;
|
||||
quantityPerUnit: number;
|
||||
totalQuantity: number;
|
||||
unit: string;
|
||||
}
|
||||
|
||||
export interface ProductionOrderStep {
|
||||
id: number;
|
||||
stepNumber: number | null;
|
||||
name: string;
|
||||
startDate: string | null;
|
||||
endDate: string | null;
|
||||
status: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
export interface ProductionOrderItem {
|
||||
id: number;
|
||||
tinyId: string;
|
||||
@@ -46,7 +68,16 @@ export interface ProductionOrderItem {
|
||||
quantity: number;
|
||||
unit: string;
|
||||
integrationStatus: string;
|
||||
notes: string;
|
||||
supplier: string;
|
||||
lotCode: string;
|
||||
rollQuantity: number | null;
|
||||
fabricKg: number | null;
|
||||
ribKg: number | null;
|
||||
yieldPiecesPerKg: number | null;
|
||||
markers: ProductionOrderMarker[];
|
||||
components: ProductionOrderComponent[];
|
||||
steps: ProductionOrderStep[];
|
||||
createdAt: string | null;
|
||||
updatedAt: string | null;
|
||||
}
|
||||
@@ -65,12 +96,43 @@ export interface ProductionOrderSummary {
|
||||
counts: ProductionOrderCounts;
|
||||
}
|
||||
|
||||
export type ProductionOrderMarkerPayload = {
|
||||
label: string;
|
||||
color?: string | null;
|
||||
};
|
||||
|
||||
export type ProductionOrderPayload = {
|
||||
number?: string;
|
||||
status?: ProductionOrderStatus;
|
||||
orderReference?: string;
|
||||
issueDate?: string | null;
|
||||
expectedDate?: string | null;
|
||||
productSku?: string;
|
||||
productDescription: string;
|
||||
quantity: number;
|
||||
unit?: string;
|
||||
integrationStatus?: string;
|
||||
markers?: ProductionOrderMarkerPayload[];
|
||||
metadata?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export interface CreateProductionOrdersResult {
|
||||
created: ProductionOrderItem[];
|
||||
skipped: Array<{
|
||||
productSku?: string;
|
||||
productDescription?: string;
|
||||
reason: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export type CutFamilyKey = 'BLCS' | 'BLOS' | 'BLMC' | 'BLPM' | 'OUTROS';
|
||||
|
||||
export interface CutProductOverride {
|
||||
familyKey?: CutFamilyKey | '';
|
||||
color?: string;
|
||||
size?: string;
|
||||
productType?: ProductTypeKey | '';
|
||||
planningNotes?: string;
|
||||
}
|
||||
|
||||
export interface CuttingSettings {
|
||||
@@ -123,6 +185,10 @@ export interface ConsumptionReference {
|
||||
efficiencyPercent: number | null;
|
||||
ribGPerPiece: number | null;
|
||||
materialCostPerKg: number | null;
|
||||
consumptionQuantity: number | null;
|
||||
consumptionUnit: string;
|
||||
source: string;
|
||||
lastProductionOrderId: number | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
@@ -164,6 +230,8 @@ export type ConsumptionReferencePayload = {
|
||||
efficiencyPercent?: number | string | null;
|
||||
ribGPerPiece?: number | string | null;
|
||||
materialCostPerKg?: number | string | null;
|
||||
consumptionQuantity?: number | string | null;
|
||||
consumptionUnit?: string | null;
|
||||
};
|
||||
|
||||
export type SupplyReceiptStatus = 'pending' | 'approved';
|
||||
@@ -242,6 +310,8 @@ export interface SupplyPurchaseNeed {
|
||||
quantitySold: number;
|
||||
stockQuantity: number;
|
||||
yieldPerKg?: number;
|
||||
consumptionQuantity?: number;
|
||||
consumptionUnit?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
@@ -405,6 +475,48 @@ export interface ProductDetailsAnalytics {
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface ProductCompositionComponent {
|
||||
id: number;
|
||||
componentTinyId: string;
|
||||
componentSku: string;
|
||||
componentName: string;
|
||||
quantityPerUnit: number;
|
||||
unit: string;
|
||||
productId: string | null;
|
||||
}
|
||||
|
||||
export interface ProductComposition {
|
||||
id: number;
|
||||
source: string;
|
||||
externalSourceId: string;
|
||||
finishedProductSku: string;
|
||||
finishedProductDescription: string;
|
||||
finishedProductUnit: string;
|
||||
finishedTinyProductId: string;
|
||||
sourceMetadata: Record<string, unknown>;
|
||||
lastSyncedAt: string | null;
|
||||
components: ProductCompositionComponent[];
|
||||
}
|
||||
|
||||
export interface ProductCompositionImportSummary {
|
||||
imported: number;
|
||||
failed: number;
|
||||
componentCount: number;
|
||||
referenceCount: number;
|
||||
skippedReferenceCount: number;
|
||||
issues: Array<{
|
||||
type: string;
|
||||
productSku?: string;
|
||||
product?: string;
|
||||
component?: string;
|
||||
componentSku?: string;
|
||||
tinyProductId?: string;
|
||||
unit?: string;
|
||||
count?: number;
|
||||
message?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface ClientAnalyticsItem {
|
||||
customerKey: string;
|
||||
clientToken: string;
|
||||
@@ -416,6 +528,25 @@ export interface ClientAnalyticsItem {
|
||||
lastPurchaseDate: string;
|
||||
}
|
||||
|
||||
export interface SellerAnalyticsItem {
|
||||
id: string;
|
||||
name: string;
|
||||
revenue: number;
|
||||
quantitySold: number;
|
||||
orderCount: number;
|
||||
customerCount: number;
|
||||
lastOrderDate: string | null;
|
||||
averageTicket: number;
|
||||
}
|
||||
|
||||
export interface SellerDetailsAnalytics {
|
||||
seller: SellerAnalyticsItem;
|
||||
chartData: Array<{ date: string; revenue: number; quantitySold: number; orderCount: number }>;
|
||||
topProducts: ProductAnalyticsItem[];
|
||||
topClients: ClientAnalyticsItem[];
|
||||
marketplaces: Array<{ name: string; revenue: number; orderCount: number }>;
|
||||
}
|
||||
|
||||
export interface ClientMetadataFilters {
|
||||
marketplace: string;
|
||||
canal_venda: string;
|
||||
|
||||
9
src/vite-env.d.ts
vendored
Normal file
9
src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_API_URL?: string;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv;
|
||||
}
|
||||
Reference in New Issue
Block a user