Compare commits

..

50 Commits

Author SHA1 Message Date
Cauê Faleiros
b431802d50 Add product composition export
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m16s
2026-07-31 10:30:49 -03:00
Cauê Faleiros
5559e9951a Support Tiny Olist V3 product compositions
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m57s
2026-07-29 15:28:00 -03:00
Cauê Faleiros
9c7a383ee2 Fix duplicate top campaign clients by phone
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m22s
2026-07-29 10:44:22 -03:00
Cauê Faleiros
496f9d432d Deduplicate top campaign clients by name
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m54s
2026-07-29 09:54:22 -03:00
Cauê Faleiros
9a7e0d6818 Normalize Turnstile env values
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 48s
2026-07-28 13:37:37 -03:00
Cauê Faleiros
6afd0c0392 Guard invalid Turnstile site key
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 46s
2026-07-28 13:24:48 -03:00
Cauê Faleiros
c741e520ea Support Portainer Turnstile env aliases
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 50s
2026-07-28 13:10:13 -03:00
Cauê Faleiros
ed3a3f0571 Load Turnstile site key at runtime
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 54s
2026-07-28 13:03:51 -03:00
Cauê Faleiros
20dde11ff0 Improve Turnstile login failure message
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 47s
2026-07-28 12:56:17 -03:00
Cauê Faleiros
4857438e6f Reapply "Use Cloudflare Turnstile secret env name"
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m35s
This reverts commit 302b55b346.
2026-07-28 12:42:27 -03:00
Cauê Faleiros
252c2447a8 Reapply "Add Turnstile captcha to login"
This reverts commit a7c732e139.
2026-07-28 12:42:27 -03:00
Cauê Faleiros
a7c732e139 Revert "Add Turnstile captcha to login"
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 52s
This reverts commit 5aa1be27bb.
2026-07-28 11:52:46 -03:00
Cauê Faleiros
302b55b346 Revert "Use Cloudflare Turnstile secret env name"
This reverts commit e8eafaf9b9.
2026-07-28 11:52:46 -03:00
Cauê Faleiros
e8eafaf9b9 Use Cloudflare Turnstile secret env name
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 49s
2026-07-28 11:46:26 -03:00
Cauê Faleiros
5aa1be27bb Add Turnstile captcha to login
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m57s
2026-07-28 11:34:47 -03:00
Cauê Faleiros
5c9ab6bb11 Add top campaign clients endpoint
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m39s
2026-07-27 14:39:44 -03:00
Cauê Faleiros
800eb976ab Derive consumption references from Tiny OP sync
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 3m16s
2026-07-23 11:53:18 -03:00
Cauê Faleiros
c07938c94e Add Tiny production order detail sync 2026-07-23 10:42:53 -03:00
Cauê Faleiros
615e07ad32 Allow DTF in campaigns
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m40s
2026-07-22 11:27:15 -03:00
Cauê Faleiros
c07264fd69 Filter campaign products to apparel 2026-07-22 11:22:49 -03:00
Cauê Faleiros
9c77475ce5 Seed default catalog categories 2026-07-22 09:21:21 -03:00
Cauê Faleiros
eaa0a5010d Clarify data cleanup ownership
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 46s
2026-07-21 15:02:30 -03:00
Cauê Faleiros
a6e07e46d9 Align purchase needs pending column
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m25s
2026-07-21 14:22:17 -03:00
Cauê Faleiros
166f97c629 Separate purchase needs status styling 2026-07-21 14:16:17 -03:00
Cauê Faleiros
98d278947b Remove purchase needs warning copy 2026-07-21 14:10:22 -03:00
Cauê Faleiros
c7536d9097 Tighten purchase needs setup table 2026-07-21 14:02:12 -03:00
Cauê Faleiros
b4e51eb8eb Refine purchase needs setup queue 2026-07-21 13:56:10 -03:00
Cauê Faleiros
51b73ed82b Add database diagnostic export
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m29s
2026-07-21 13:20:48 -03:00
Cauê Faleiros
eb5cc01d56 Paginate stock inventory tab
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m25s
2026-07-21 11:30:00 -03:00
Cauê Faleiros
4c3df482be Match fabric planning card heights 2026-07-21 11:15:41 -03:00
Cauê Faleiros
377754c26e Move fabric plan form to right side 2026-07-21 11:02:29 -03:00
Cauê Faleiros
bc74a1089b Show Tiny stock in inventory control
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m14s
2026-07-21 10:46:24 -03:00
Cauê Faleiros
f992517891 Reposition fabric planning form 2026-07-21 10:11:23 -03:00
Cauê Faleiros
6283f86091 Simplify cutting page workflow views
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m44s
2026-07-20 15:16:03 -03:00
Cauê Faleiros
e1f001b57d Add production order generation preview
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m20s
2026-07-20 14:12:55 -03:00
Cauê Faleiros
88089343a8 Add local production order workflow
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m40s
2026-07-20 12:57:28 -03:00
Cauê Faleiros
9af5f296a7 Improve planning data normalization
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m25s
2026-07-20 11:24:47 -03:00
Cauê Faleiros
3066ba06a2 Refine planning product classification
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m15s
2026-07-20 10:16:58 -03:00
Cauê Faleiros
6608957574 Separate accessory replenishment queue
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m25s
2026-07-17 11:02:20 -03:00
Cauê Faleiros
1dcc565299 Standardize operations page layout 2026-07-17 10:10:59 -03:00
Cauê Faleiros
0cae89277f Simplify pending issue labels
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m12s
2026-07-17 09:27:08 -03:00
Cauê Faleiros
e332374b2d Polish supply planning copy 2026-07-17 09:21:58 -03:00
Cauê Faleiros
e62ec6052e Add current data supply planning
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m34s
2026-07-16 14:34:07 -03:00
Cauê Faleiros
4bfd0d4591 Improve users and purchase needs UI
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 35s
2026-07-16 12:56:11 -03:00
Cauê Faleiros
897d4cbe66 Add planning issues work queue
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m10s
2026-07-16 12:53:17 -03:00
Cauê Faleiros
8eeadf00ae Add manual SKU planning overrides
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m18s
2026-07-16 11:48:52 -03:00
Cauê Faleiros
422828f343 Add inferred product classification
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m36s
2026-07-16 10:15:24 -03:00
Cauê Faleiros
5e81a8f2c3 Update project data flow context
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m35s
2026-07-15 10:01:54 -03:00
Cauê Faleiros
f92348958b Make dashboard trend line optional
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m6s
2026-07-14 14:51:07 -03:00
Cauê Faleiros
ba9cc3ad01 Fix large sold quantity icon layout 2026-07-14 14:23:28 -03:00
48 changed files with 5886 additions and 653 deletions

View File

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

View File

@@ -37,6 +37,7 @@ 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. * **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. * **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. * **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.
## 4. Directory Structure ## 4. Directory Structure
```text ```text
@@ -83,15 +84,50 @@ 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. * **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. * **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 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.
* **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:**
* `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.
* 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.
* `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.
## 7. CI/CD & Deployment
* **Gitea Actions:** A workflow located in `.gitea/workflows/deploy.yml` triggers on pushes to the `main` branch. * **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/`. * **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. * **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`. * **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:** **Running Locally:**
1. Start the database: 1. Start the database:
@@ -132,7 +168,7 @@ docker compose up -d --build
``` ```
The local Docker services use `restart: unless-stopped`, so containers should come back after laptop restart if Docker starts. The local Docker services use `restart: unless-stopped`, so containers should come back after laptop restart if Docker starts.
## 8. Coding Standards & AI Directives ## 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`. * **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. * **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`. * **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 +177,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. * **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. * **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: 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. * `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. * `ef27cff Add seller performance dashboard charts` - dashboard now includes seller revenue/order charts.
@@ -165,3 +201,20 @@ Files most relevant to RFV:
* `src/components/DateRangePicker.tsx` and `src/dateRanges.ts` - local calendar date ranges and ISO query param formatting. * `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/dataService.ts` - analytics fetch/cache helpers and client filter option requests.
* `src/displayFormatters.ts` - shared seller/fantasy/client metadata display cleanup. * `src/displayFormatters.ts` - shared seller/fantasy/client metadata display cleanup.
Recent commits related to supply/cutting/data-flow and chart readability:
* `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.

View File

@@ -11,4 +11,4 @@ FROM nginx:alpine
COPY --from=build /app/dist /usr/share/nginx/html COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80 EXPOSE 80
CMD ["nginx", "-g", "daemon off;"] CMD ["nginx", "-g", "daemon off;"]

View File

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

View File

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

View File

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

View File

@@ -10,6 +10,7 @@ const {
getProductDetailsAnalytics, getProductDetailsAnalytics,
getRfmAnalytics getRfmAnalytics
} = require('../services/analyticsService'); } = require('../services/analyticsService');
const { getProductComposition, listProductCompositions } = require('../services/productionOrderService');
const router = express.Router(); const router = express.Router();
@@ -58,6 +59,25 @@ router.get('/analytics/products/:productId/details', verifyToken, async (req, re
} }
}); });
router.get('/analytics/products/:productId/composition', verifyToken, async (req, res) => {
try {
const composition = await getProductComposition(req.params.productId);
res.json({ composition });
} catch (error) {
console.error('Error fetching product composition:', error);
res.status(500).json({ error: 'Internal Server Error' });
}
});
router.get('/analytics/product-compositions', verifyToken, async (req, res) => {
try {
res.json({ compositions: await listProductCompositions() });
} catch (error) {
console.error('Error exporting product compositions:', error);
res.status(500).json({ error: 'Internal Server Error' });
}
});
router.get('/analytics/clients', verifyToken, async (req, res) => { router.get('/analytics/clients', verifyToken, async (req, res) => {
try { try {
res.json(await getClientAnalytics(getClientAnalyticsFilters(req.query))); res.json(await getClientAnalytics(getClientAnalyticsFilters(req.query)));

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,6 +2,21 @@ const { pool } = require('../db');
const FAMILY_KEYS = ['BLCS', 'BLOS', 'BLMC', 'BLPM']; const FAMILY_KEYS = ['BLCS', 'BLOS', 'BLMC', 'BLPM'];
const FAMILY_KEY_SET = new Set(FAMILY_KEYS); 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 normalizeFamilyKey = (value) => {
const familyKey = String(value || '').trim().toUpperCase(); const familyKey = String(value || '').trim().toUpperCase();
@@ -15,6 +30,11 @@ const normalizeNumber = (value) => {
const normalizeText = (value) => String(value || '').replace(/\s+/g, ' ').trim(); 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 = {}) => { const normalizeFamilyYields = (familyYields = {}) => {
return FAMILY_KEYS.reduce((normalized, familyKey) => { return FAMILY_KEYS.reduce((normalized, familyKey) => {
const unitsPerRoll = normalizeNumber(familyYields[familyKey]); const unitsPerRoll = normalizeNumber(familyYields[familyKey]);
@@ -31,13 +51,17 @@ const normalizeProductOverrides = (productOverrides = {}) => {
const familyKey = normalizeFamilyKey(override.familyKey); const familyKey = normalizeFamilyKey(override.familyKey);
const color = normalizeText(override.color); const color = normalizeText(override.color);
const size = normalizeText(override.size).toUpperCase(); 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] = { normalized[normalizedProductId] = {
familyKey, familyKey,
color, color,
size size,
productType,
planningNotes
}; };
return normalized; return normalized;
@@ -53,7 +77,7 @@ const listCuttingSettings = async () => {
ORDER BY family_key ORDER BY family_key
`), `),
pool.query(` pool.query(`
SELECT product_id, family_key, color, size SELECT product_id, family_key, color, size, product_type, planning_notes
FROM cutting_product_overrides FROM cutting_product_overrides
ORDER BY product_id ORDER BY product_id
`) `)
@@ -68,7 +92,9 @@ const listCuttingSettings = async () => {
settings[row.product_id] = { settings[row.product_id] = {
familyKey: row.family_key || '', familyKey: row.family_key || '',
color: row.color || '', color: row.color || '',
size: row.size || '' size: row.size || '',
productType: row.product_type || '',
planningNotes: row.planning_notes || ''
}; };
return settings; return settings;
}, {}) }, {})
@@ -94,13 +120,15 @@ const saveCuttingSettings = async ({ familyYields = {}, productOverrides = {} })
await client.query('DELETE FROM cutting_product_overrides'); await client.query('DELETE FROM cutting_product_overrides');
for (const [productId, override] of Object.entries(normalizedProductOverrides)) { for (const [productId, override] of Object.entries(normalizedProductOverrides)) {
await client.query(` await client.query(`
INSERT INTO cutting_product_overrides (product_id, family_key, color, size, updated_at) INSERT INTO cutting_product_overrides (product_id, family_key, color, size, product_type, planning_notes, updated_at)
VALUES ($1, $2, $3, $4, CURRENT_TIMESTAMP) VALUES ($1, $2, $3, $4, $5, $6, CURRENT_TIMESTAMP)
`, [ `, [
productId, productId,
override.familyKey || null, override.familyKey || null,
override.color || null, override.color || null,
override.size || null override.size || null,
override.productType || null,
override.planningNotes || null
]); ]);
} }
@@ -123,6 +151,7 @@ module.exports = {
listCuttingSettings, listCuttingSettings,
normalizeFamilyKey, normalizeFamilyKey,
normalizeFamilyYields, normalizeFamilyYields,
normalizeProductType,
normalizeProductOverrides, normalizeProductOverrides,
saveCuttingSettings saveCuttingSettings
}; };

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -24,6 +24,13 @@ const normalizeKey = (value) => normalizeText(value)
const normalizeSku = (value) => normalizeText(value).toUpperCase(); const normalizeSku = (value) => normalizeText(value).toUpperCase();
const normalizeUnit = (value) => {
const unit = normalizeText(value).toLowerCase();
if (['kg', 'quilo', 'quilos'].includes(unit)) return 'kg';
if (['un', 'und', 'un.', 'unidade', 'unidades'].includes(unit)) return 'un.';
return unit || 'un.';
};
const mapReceipt = (row) => ({ const mapReceipt = (row) => ({
id: row.id, id: row.id,
category: row.category, category: row.category,
@@ -249,7 +256,10 @@ const listConsumptionReferenceRows = async () => {
m.name AS material_name, m.name AS material_name,
r.color, r.color,
r.general_yield, r.general_yield,
r.size_yields r.size_yields,
r.consumption_quantity,
r.consumption_unit,
r.source
FROM consumption_references r FROM consumption_references r
JOIN catalog_products p ON p.id = r.product_id JOIN catalog_products p ON p.id = r.product_id
LEFT JOIN catalog_products m ON m.id = r.material_product_id LEFT JOIN catalog_products m ON m.id = r.material_product_id
@@ -296,7 +306,12 @@ const buildProjectPurchaseNeeds = async (lots, receipts) => {
listProjectDemandRows(), listProjectDemandRows(),
listConsumptionReferenceRows() listConsumptionReferenceRows()
]); ]);
const referencesBySku = new Map(referenceRows.map(reference => [normalizeSku(reference.product_sku), reference])); const referencesBySku = referenceRows.reduce((references, reference) => {
const sku = normalizeSku(reference.product_sku);
if (!sku) return references;
references.set(sku, [...(references.get(sku) || []), reference]);
return references;
}, new Map());
const needsByMaterial = new Map(); const needsByMaterial = new Map();
demandRows.forEach(row => { demandRows.forEach(row => {
@@ -309,8 +324,8 @@ const buildProjectPurchaseNeeds = async (lots, receipts) => {
const suggestedQuantity = Math.max(Math.ceil(projectedDemand - stockQuantity), 0); const suggestedQuantity = Math.max(Math.ceil(projectedDemand - stockQuantity), 0);
if (suggestedQuantity <= 0) return; if (suggestedQuantity <= 0) return;
const reference = referencesBySku.get(productId); const references = referencesBySku.get(productId) || [];
if (!reference) { if (!references.length) {
mergeNeedLine(needsByMaterial, `missing:${productId}`, { mergeNeedLine(needsByMaterial, `missing:${productId}`, {
material: `Cadastrar consumo: ${normalizeText(row.product_name) || productId}`, material: `Cadastrar consumo: ${normalizeText(row.product_name) || productId}`,
plannedKg: suggestedQuantity, plannedKg: suggestedQuantity,
@@ -329,56 +344,81 @@ const buildProjectPurchaseNeeds = async (lots, receipts) => {
return; return;
} }
const yieldPerKg = getReferenceYield(reference); references.forEach(reference => {
if (!yieldPerKg) { const consumptionQuantity = Number(reference.consumption_quantity || 0);
mergeNeedLine(needsByMaterial, `missing-yield:${productId}`, { const consumptionUnit = normalizeUnit(reference.consumption_unit);
material: `Cadastrar rendimento: ${normalizeText(row.product_name) || productId}`, const materialName = normalizeText(reference.material_name) || normalizeText(reference.material_sku) || 'Material sem cadastro';
plannedKg: suggestedQuantity,
priority: 'Crítico', if (consumptionQuantity > 0) {
unit: 'un.', mergeNeedLine(needsByMaterial, `${consumptionUnit}:${normalizeKey(materialName)}`, {
source: 'project_demand', material: materialName,
missingReference: true, 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}:${reference.material_product_id || 'material'}`, {
material: `Cadastrar rendimento: ${normalizeText(row.product_name) || productId}`,
plannedKg: suggestedQuantity,
priority: 'Crítico',
unit: 'un.',
source: 'project_demand',
missingReference: true,
color: reference.color,
product: {
productId,
name: normalizeText(row.product_name),
suggestedQuantity,
quantitySold,
stockQuantity
}
});
return;
}
mergeNeedLine(needsByMaterial, `kg:${normalizeKey(materialName)}`, {
material: materialName,
plannedKg: suggestedQuantity / yieldPerKg,
priority: 'Atenção',
unit: 'kg',
source: reference.source || 'project_demand',
color: reference.color, color: reference.color,
product: { product: {
productId, productId,
name: normalizeText(row.product_name), name: normalizeText(row.product_name),
suggestedQuantity, suggestedQuantity,
quantitySold, quantitySold,
stockQuantity stockQuantity,
yieldPerKg
} }
}); });
return;
}
const materialName = normalizeText(reference.material_name) || normalizeText(reference.material_sku) || 'Material sem cadastro';
mergeNeedLine(needsByMaterial, normalizeKey(materialName), {
material: materialName,
plannedKg: suggestedQuantity / yieldPerKg,
priority: 'Atenção',
unit: 'kg',
source: 'project_demand',
color: reference.color,
product: {
productId,
name: normalizeText(row.product_name),
suggestedQuantity,
quantitySold,
stockQuantity,
yieldPerKg
}
}); });
}); });
lots.forEach(lot => { lots.forEach(lot => {
if (lot.unit !== 'kg') return; const need = needsByMaterial.get(`${normalizeUnit(lot.unit)}:${normalizeKey(lot.product)}`);
const need = needsByMaterial.get(normalizeKey(lot.product)); if (need) need.stockKg += lot.quantity;
if (need && need.unit === 'kg') need.stockKg += lot.quantity;
}); });
receipts.forEach(receipt => { receipts.forEach(receipt => {
if (receipt.status !== 'pending' || receipt.unit !== 'kg') return; if (receipt.status !== 'pending') return;
const need = needsByMaterial.get(normalizeKey(receipt.product)); const need = needsByMaterial.get(`${normalizeUnit(receipt.unit)}:${normalizeKey(receipt.product)}`);
if (need && need.unit === 'kg') need.pendingKg += receipt.quantity; if (need) need.pendingKg += receipt.quantity;
}); });
return Array.from(needsByMaterial.values()).map(need => { return Array.from(needsByMaterial.values()).map(need => {

View File

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

View File

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

View File

@@ -4,6 +4,7 @@ const test = require('node:test');
const { const {
normalizeFamilyKey, normalizeFamilyKey,
normalizeFamilyYields, normalizeFamilyYields,
normalizeProductType,
normalizeProductOverrides normalizeProductOverrides
} = require('../services/cuttingSettingsService'); } = 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', () => { test('normalizeProductOverrides trims and removes empty overrides', () => {
assert.deepEqual(normalizeProductOverrides({ 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-2': { familyKey: 'OUTROS', color: '', size: '' },
'SKU-3': { familyKey: '', color: ' Branco ', 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-1': { familyKey: 'BLCS', color: 'Preto', size: 'M', productType: 'finished_apparel', planningNotes: 'revisar corte' },
'SKU-3': { familyKey: '', color: 'Branco', size: '' } 'SKU-3': { familyKey: '', color: 'Branco', size: '', productType: '', planningNotes: '' },
'SKU-4': { familyKey: '', color: '', size: '', productType: 'raw_material', planningNotes: '' }
}); });
}); });

View File

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

View File

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

View File

@@ -10,6 +10,7 @@ const ProductDetails = React.lazy(() => import('./pages/ProductDetails'));
const ProductGroupDetails = React.lazy(() => import('./pages/ProductGroupDetails')); const ProductGroupDetails = React.lazy(() => import('./pages/ProductGroupDetails'));
const Replenishment = React.lazy(() => import('./pages/Replenishment')); const Replenishment = React.lazy(() => import('./pages/Replenishment'));
const Cutting = React.lazy(() => import('./pages/Cutting')); const Cutting = React.lazy(() => import('./pages/Cutting'));
const PlanningIssues = React.lazy(() => import('./pages/PlanningIssues'));
const ProductionOrders = React.lazy(() => import('./pages/ProductionOrders')); const ProductionOrders = React.lazy(() => import('./pages/ProductionOrders'));
const Supplies = React.lazy(() => import('./pages/Supplies')); const Supplies = React.lazy(() => import('./pages/Supplies'));
const Clients = React.lazy(() => import('./pages/Clients')); const Clients = React.lazy(() => import('./pages/Clients'));
@@ -54,6 +55,7 @@ function App() {
<Route path="products/:id" element={<ProductDetails />} /> <Route path="products/:id" element={<ProductDetails />} />
<Route path="replenishment" element={<Replenishment />} /> <Route path="replenishment" element={<Replenishment />} />
<Route path="cutting" element={<Cutting />} /> <Route path="cutting" element={<Cutting />} />
<Route path="planning-issues" element={<PlanningIssues />} />
<Route path="supplies" element={<Supplies />} /> <Route path="supplies" element={<Supplies />} />
<Route path="supplies/:section" element={<Supplies />} /> <Route path="supplies/:section" element={<Supplies />} />
<Route path="stock" element={<Navigate to="/products" replace />} /> <Route path="stock" element={<Navigate to="/products" replace />} />

View File

@@ -80,11 +80,11 @@ test('buildCutPlan estimates rolls when a family yield is configured', () => {
assert.equal(plan.summary.estimatedRolls, 3); 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([ const plan = buildCutPlan([
product({ product({
id: 'SKU-2', id: 'SKU-2',
name: 'BONÉ PRETO', name: 'CAMISETA SEM PADRAO',
quantitySold: 70, quantitySold: 70,
stock: 0 stock: 0
}) })
@@ -102,6 +102,51 @@ test('buildCutPlan applies per-product planning overrides', () => {
assert.deepEqual(plan.needRows[0].issues, []); 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', () => { test('buildOpenProductionByProductId matches open OPs by SKU and normalized product variant', () => {
const products = [ const products = [
product({ id: 'SKU-1' }), 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 }); 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([ const plan = buildCutPlan([
product({ product({
id: 'SKU-2', id: 'SKU-2',
name: 'BONÉ PRETO', name: 'VESTUARIO SEM PADRAO',
quantitySold: 70, quantitySold: 70,
stock: 0 stock: 0
}) })

View File

@@ -1,5 +1,7 @@
import type { CutFamilyKey, CutProductOverride, DateRange, ProductAnalyticsItem, ProductionOrderItem } from '../types'; import type { CutFamilyKey, CutProductOverride, DateRange, ProductAnalyticsItem, ProductionOrderItem } from '../types';
import { normalizeProductText, parseProductName, sortProductSizes } from '../productParsing.ts'; import { normalizeProductText, parseProductName, sortProductSizes } from '../productParsing.ts';
import { resolveProductType } from '../productClassification.ts';
import { getPlanningStock } from '../planningStock.ts';
export type { CutFamilyKey, CutProductOverride }; export type { CutFamilyKey, CutProductOverride };
@@ -201,8 +203,11 @@ export const buildCutPlan = (
options: CutPlanOptions = {} options: CutPlanOptions = {}
): CutPlan => { ): CutPlan => {
const rangeDays = getRangeDays(dateRange); 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 metadata = parseProductName(product.name);
const override = options.productOverrides?.[product.id]; const override = options.productOverrides?.[product.id];
const overrideRule = override?.familyKey ? FAMILY_RULES_BY_KEY.get(override.familyKey) : undefined; 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 dailySales = product.quantitySold / rangeDays;
const projectedDemand = dailySales * targetCoverageDays; const projectedDemand = dailySales * targetCoverageDays;
const openProductionQuantity = openProductionByProductId[product.id] || 0; const openProductionQuantity = openProductionByProductId[product.id] || 0;
const availableQuantity = product.stock + openProductionQuantity; const availableQuantity = getPlanningStock(product.stock) + openProductionQuantity;
const suggestedCutQuantity = Math.max(0, Math.ceil(projectedDemand - availableQuantity)); const suggestedCutQuantity = Math.max(0, Math.ceil(projectedDemand - availableQuantity));
const estimatedRolls = family.unitsPerRoll && suggestedCutQuantity > 0 const estimatedRolls = family.unitsPerRoll && suggestedCutQuantity > 0
? Math.ceil(suggestedCutQuantity / family.unitsPerRoll) ? Math.ceil(suggestedCutQuantity / family.unitsPerRoll)

View File

@@ -1,6 +1,6 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { Outlet, Link, useLocation } from 'react-router-dom'; import { Outlet, Link, useLocation } from 'react-router-dom';
import { LayoutDashboard, Users, BarChart3, ChevronLeft, ChevronRight, Package, 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 } from 'lucide-react';
import type { DateRange, OrderData } from '../types'; import type { DateRange, OrderData } from '../types';
import { isSuperAdmin, logout } from '../dataService'; import { isSuperAdmin, logout } from '../dataService';
import { rangeForLastDays } from '../dateRanges'; import { rangeForLastDays } from '../dateRanges';
@@ -73,6 +73,7 @@ const Layout = () => {
label: 'Operação', label: 'Operação',
items: [ items: [
{ name: 'Produtos', href: '/products', icon: Package }, { name: 'Produtos', href: '/products', icon: Package },
{ name: 'Dados Pendentes', href: '/planning-issues', icon: ClipboardList },
{ name: 'Cadastros', href: '/registrations', icon: Tags }, { name: 'Cadastros', href: '/registrations', icon: Tags },
{ name: 'Suprimentos', href: '/supplies', icon: Boxes }, { name: 'Suprimentos', href: '/supplies', icon: Boxes },
], ],

View 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;

View 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;

View File

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

View File

@@ -1,5 +1,6 @@
import { useEffect, useMemo, useState, type FormEvent } from 'react'; import { useEffect, useMemo, useState, type FormEvent } from 'react';
import { import {
AlertTriangle,
AtSign, AtSign,
Check, Check,
Copy, Copy,
@@ -8,10 +9,13 @@ import {
Loader2, Loader2,
Mail, Mail,
Plus, Plus,
RefreshCw,
Search, Search,
Shield,
Trash2, Trash2,
User as UserIcon, User as UserIcon,
UserCheck,
UserPlus,
UserX,
X X
} from 'lucide-react'; } from 'lucide-react';
import { createUser, deleteUser as deleteManagedUser, fetchUsers, updateUser } from '../dataService'; import { createUser, deleteUser as deleteManagedUser, fetchUsers, updateUser } from '../dataService';
@@ -31,25 +35,30 @@ const statusOptions: Array<{ key: StatusFilter; label: string }> = [
{ key: 'inactive', label: 'Inativos' } { key: 'inactive', label: 'Inativos' }
]; ];
const formatDateTime = (value: string) => { const roleLabels: Record<string, string> = {
const date = new Date(value); admin: 'Admin',
if (!value || Number.isNaN(date.getTime())) return '-'; supervisor: 'Supervisor',
producao: 'Produção',
return new Intl.DateTimeFormat('pt-BR', { operacao: 'Operação',
day: '2-digit', vendedor: 'Vendedor',
month: 'short', financeiro: 'Financeiro',
year: 'numeric', user: 'Usuário'
hour: '2-digit',
minute: '2-digit'
}).format(date);
}; };
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 getInitials = (name: string) => {
const parts = name.trim().split(/\s+/).filter(Boolean); const parts = name.trim().split(/\s+/).filter(Boolean);
if (!parts.length) return '?'; if (!parts.length) return '?';
return parts.slice(0, 2).map((part) => part[0]).join('').toUpperCase(); 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 AdminUsers = () => {
const [users, setUsers] = useState<ManagedUser[]>([]); const [users, setUsers] = useState<ManagedUser[]>([]);
const [searchTerm, setSearchTerm] = useState(''); const [searchTerm, setSearchTerm] = useState('');
@@ -87,6 +96,8 @@ const AdminUsers = () => {
}); });
}, [searchTerm, statusFilter, users]); }, [searchTerm, statusFilter, users]);
const hasFilters = searchTerm.trim() !== '' || statusFilter !== 'all';
const loadUsers = async () => { const loadUsers = async () => {
setIsLoading(true); setIsLoading(true);
try { try {
@@ -119,6 +130,11 @@ const AdminUsers = () => {
resetForm(); resetForm();
}; };
const clearFilters = () => {
setSearchTerm('');
setStatusFilter('all');
};
const openCreateModal = () => { const openCreateModal = () => {
resetForm(); resetForm();
setIsModalOpen(true); setIsModalOpen(true);
@@ -251,129 +267,200 @@ const AdminUsers = () => {
? 'border-amber-500/30 bg-amber-500/10 text-amber-200' ? 'border-amber-500/30 bg-amber-500/10 text-amber-200'
: 'border-emerald-500/30 bg-emerald-500/10 text-emerald-300'; : '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 ( return (
<div className="space-y-5"> <div className="space-y-5">
<header className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between"> <header className="flex flex-col gap-4 xl:flex-row xl:items-end xl:justify-between">
<div> <div className="max-w-3xl">
<div className="mb-2 flex items-center gap-2 text-sm font-semibold text-brand-primary"> <h1 className="text-3xl font-bold text-dark-text">Usuários</h1>
<Shield className="h-4 w-4" /> <p className="mt-2 text-sm leading-6 text-dark-muted">
Super admin Gerencie acessos individuais, senhas iniciais e bloqueios de entrada no painel.
</div> </p>
<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>
</div> </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 <button
type="button" type="button"
onClick={openCreateModal} 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" 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" /> <UserPlus className="h-4 w-4" />
Novo usuário Novo acesso
</button> </button>
</div> </div>
</header> </header>
<section className="rounded-lg border border-dark-border bg-dark-card"> <section className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-4">
<div className="flex flex-col gap-4 border-b border-dark-border p-4 lg:flex-row lg:items-center lg:justify-between"> {stats.map((stat) => {
<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]"> const Icon = stat.icon;
<div className="px-4 py-3"> return (
<p className="text-xs font-semibold text-dark-muted">Total</p> <div key={stat.label} className="rounded-2xl border border-dark-border bg-dark-card p-4 shadow-sm">
<p className="mt-1 text-xl font-bold text-dark-text">{users.length}</p> <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="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>
</div> </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> </section>
</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>
<div className="flex flex-col gap-3 md:flex-row md:items-center"> <section className="overflow-hidden rounded-2xl border border-dark-border bg-dark-card shadow-sm">
<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="border-b border-dark-border p-4">
<Search className="h-4 w-4 shrink-0 text-dark-muted" /> <div className="flex flex-col gap-4 xl:flex-row xl:items-center xl:justify-between">
<input <div>
type="search" <h2 className="text-base font-bold text-dark-text">Acessos cadastrados</h2>
value={searchTerm} <p className="mt-1 text-sm text-dark-muted">Busque, filtre e edite os usuários do painel.</p>
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"
placeholder="Buscar nome ou e-mail"
/>
</div> </div>
<div className="grid grid-cols-3 rounded-lg border border-dark-border bg-dark-input p-1"> <div className="flex flex-col gap-3 lg:flex-row lg:items-center">
{statusOptions.map((option) => ( <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">
<button <Search className="h-4 w-4 shrink-0 text-dark-muted" />
key={option.key} <input
type="button" type="search"
onClick={() => setStatusFilter(option.key)} value={searchTerm}
className={`h-8 cursor-pointer rounded-md px-3 text-sm font-semibold transition-colors ${ onChange={(event) => setSearchTerm(event.target.value)}
statusFilter === option.key className="min-w-0 flex-1 bg-transparent text-sm font-semibold text-dark-text outline-none placeholder:text-dark-muted"
? 'bg-dark-card text-dark-text shadow-sm' placeholder="Buscar nome ou e-mail"
: 'text-dark-muted hover:text-dark-text' />
}`} {searchTerm && (
> <button
{option.label} type="button"
</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">
{statusOptions.map((option) => (
<button
key={option.key}
type="button"
onClick={() => setStatusFilter(option.key)}
className={`h-8 cursor-pointer rounded-md px-3 text-sm font-semibold transition-colors ${
statusFilter === option.key
? 'bg-dark-card text-dark-text shadow-sm'
: 'text-dark-muted hover:text-dark-text'
}`}
>
{option.label}
<span className="ml-1 text-xs opacity-70">{statusCounts[option.key]}</span>
</button>
))}
</div>
</div> </div>
</div> </div>
</div> </div>
{isLoading ? ( {isLoading ? (
<div className="flex h-80 items-center justify-center text-brand-primary"> <div className="divide-y divide-dark-border" aria-label="Carregando usuários">
<Loader2 className="h-7 w-7 animate-spin" /> {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> </div>
) : users.length === 0 ? ( ) : users.length === 0 ? (
<div className="flex h-80 flex-col items-center justify-center px-6 text-center"> <div className="flex min-h-[360px] flex-col items-center justify-center px-6 py-12 text-center">
<div className="mb-4 flex h-12 w-12 items-center justify-center rounded-lg bg-dark-input text-dark-muted"> <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">
<UserIcon className="h-6 w-6" /> <UserPlus className="h-7 w-7" />
</div> </div>
<p className="text-base font-bold text-dark-text">Nenhum usuário cadastrado</p> <p className="text-lg 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="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> </div>
) : filteredUsers.length === 0 ? ( ) : filteredUsers.length === 0 ? (
<div className="flex h-80 flex-col items-center justify-center px-6 text-center"> <div className="flex min-h-[320px] flex-col items-center justify-center px-6 py-12 text-center">
<div className="mb-4 flex h-12 w-12 items-center justify-center rounded-lg bg-dark-input text-dark-muted"> <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-6 w-6" /> <Search className="h-7 w-7" />
</div> </div>
<p className="text-base font-bold text-dark-text">Nenhum resultado</p> <p className="text-lg 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="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>
) : ( ) : (
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full min-w-[760px] text-left"> <table className="w-full min-w-[900px] table-fixed text-left">
<thead className="border-b border-dark-border text-xs uppercase tracking-widest text-dark-muted"> <thead className="border-b border-dark-border bg-dark-input/35 text-xs uppercase tracking-widest text-dark-muted">
<tr> <tr>
<th className="px-5 py-3 font-bold">Usuário</th> <th className="w-[34%] px-5 py-3 font-bold">Usuário</th>
<th className="px-5 py-3 font-bold">E-mail</th> <th className="w-[24%] px-5 py-3 font-bold">E-mail</th>
<th className="px-5 py-3 font-bold">Status</th> <th className="w-[16%] px-5 py-3 font-bold">Perfil</th>
<th className="px-5 py-3 text-right font-bold">Criado em</th> <th className="w-[14%] px-5 py-3 font-bold">Status</th>
<th className="px-5 py-3 text-right font-bold">Ações</th> <th className="w-[12%] px-5 py-3 text-right font-bold">Ações</th>
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-dark-border"> <tbody className="divide-y divide-dark-border">
{filteredUsers.map((user) => ( {filteredUsers.map((user) => (
<tr key={user.id} className="text-sm transition-colors hover:bg-dark-input/45"> <tr key={user.id} className="text-sm transition-colors hover:bg-dark-input/45">
<td className="px-5 py-4"> <td className="px-5 py-4">
<div className="flex items-center gap-3"> <div className="flex min-w-0 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 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)} {getInitials(user.name)}
</div> </div>
<div className="min-w-0"> <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>
</div> </div>
</td> </td>
<td className="px-5 py-4"> <td className="px-5 py-4">
<div className="flex min-w-0 items-center gap-2 text-dark-muted"> <div className="flex min-w-0 items-center gap-2 text-dark-muted">
<Mail className="h-4 w-4 shrink-0" /> <Mail className="h-4 w-4 shrink-0" />
<span className="truncate">{user.email}</span> <span className="truncate font-semibold">{user.email}</span>
</div> </div>
</td> </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"> <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 ${ <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' 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'} {user.isActive ? 'Ativo' : 'Inativo'}
</span> </span>
</td> </td>
<td className="px-5 py-4 text-right text-dark-muted">{formatDateTime(user.createdAt)}</td>
<td className="px-5 py-4"> <td className="px-5 py-4">
<div className="flex justify-end gap-2"> <div className="flex justify-end gap-2">
<button <button
type="button" type="button"
onClick={() => openEditModal(user)} 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}`} aria-label={`Editar ${user.name}`}
title="Editar" title="Editar"
> >
@@ -400,7 +486,7 @@ const AdminUsers = () => {
setNotice(null); setNotice(null);
setUserToDelete(user); 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}`} aria-label={`Excluir ${user.name}`}
title="Excluir" title="Excluir"
> >
@@ -417,14 +503,19 @@ const AdminUsers = () => {
</section> </section>
{isModalOpen && ( {isModalOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 px-4 py-6"> <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-lg overflow-hidden rounded-lg border border-dark-border bg-dark-card shadow-2xl"> <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 border-b border-dark-border px-5 py-4"> <div className="flex items-start justify-between gap-4 border-b border-dark-border px-5 py-4">
<div> <div className="flex items-start gap-3">
<h2 className="text-lg font-bold text-dark-text">{editingUser ? 'Editar usuário' : 'Novo usuário'}</h2> <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">
<p className="mt-1 text-sm text-dark-muted"> {editingUser ? <Edit3 className="h-5 w-5" /> : <UserPlus className="h-5 w-5" />}
{editingUser ? 'Atualize dados, status e senha de acesso.' : 'Crie um acesso individual para o painel.'} </div>
</p> <div>
<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> </div>
<button <button
type="button" type="button"
@@ -437,120 +528,157 @@ const AdminUsers = () => {
</button> </button>
</div> </div>
<form onSubmit={editingUser ? handleUpdateUser : handleCreateUser} className="space-y-4 p-5"> <form onSubmit={editingUser ? handleUpdateUser : handleCreateUser}>
<label className="block"> <div className="space-y-5 p-5">
<span className="mb-2 block text-xs font-bold uppercase tracking-widest text-dark-muted">Nome</span> <section>
<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="mb-3">
<UserIcon className="h-4 w-4 shrink-0 text-dark-muted" /> <h3 className="text-sm font-bold text-dark-text">Identificação</h3>
<input <p className="mt-1 text-xs font-semibold text-dark-muted">Use um nome reconhecível e um e-mail individual.</p>
type="text" </div>
value={name} <div className="grid grid-cols-1 gap-4 md:grid-cols-2">
onChange={(event) => setName(event.target.value)} <label className="block">
className="min-w-0 flex-1 bg-transparent text-dark-text outline-none placeholder:text-dark-muted" <span className="mb-2 block text-xs font-bold uppercase tracking-widest text-dark-muted">Nome</span>
placeholder="Nome completo" <div className={inputWrapClassName}>
required <UserIcon className="h-4 w-4 shrink-0 text-dark-muted" />
autoFocus <input
/> type="text"
</div> value={name}
</label> onChange={(event) => setName(event.target.value)}
className={inputClassName}
placeholder="Nome completo"
required
autoFocus
/>
</div>
</label>
<label className="block"> <label className="block">
<span className="mb-2 block text-xs font-bold uppercase tracking-widest text-dark-muted">E-mail</span> <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" /> <AtSign className="h-4 w-4 shrink-0 text-dark-muted" />
<input <input
type="email" type="email"
value={email} value={email}
onChange={(event) => setEmail(event.target.value)} 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" placeholder="usuario@empresa.com"
required required
/> />
</div> </div>
</label> </label>
</div>
</section>
<div> <section>
<span className="mb-2 block text-xs font-bold uppercase tracking-widest text-dark-muted">{editingUser ? 'Senha' : 'Senha inicial'}</span> <div className="mb-3">
<div className="grid grid-cols-2 rounded-lg border border-dark-border bg-dark-input p-1"> <h3 className="text-sm font-bold text-dark-text">{editingUser ? 'Senha' : 'Senha inicial'}</h3>
<button <p className="mt-1 text-xs font-semibold text-dark-muted">
type="button" {editingUser ? 'Mantenha a senha atual ou defina uma nova.' : 'Gere uma senha temporária ou defina uma senha manual.'}
onClick={() => setPasswordMode('auto')} </p>
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'}`} </div>
> <div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
{editingUser ? 'Manter' : 'Gerar'} <button
</button> type="button"
<button onClick={() => setPasswordMode('auto')}
type="button" className={`cursor-pointer rounded-xl border p-4 text-left transition-colors ${
onClick={() => setPasswordMode('manual')} passwordMode === 'auto'
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'}`} ? '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'} }`}
</button> >
</div> <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={`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'
}`}
>
<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 será combinada fora do sistema.
</span>
</button>
</div>
{passwordMode === 'manual' && ( {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">
<KeyRound className="h-4 w-4 shrink-0 text-dark-muted" /> <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={inputClassName}
placeholder="Mínimo de 6 caracteres"
minLength={6}
required
/>
</div>
</div>
)}
</section>
{editingUser && (
<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-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 <input
type="password" type="checkbox"
value={password} checked={isActive}
onChange={(event) => setPassword(event.target.value)} onChange={(event) => setIsActive(event.target.checked)}
className="min-w-0 flex-1 bg-transparent text-dark-text outline-none placeholder:text-dark-muted" className="h-5 w-5 cursor-pointer accent-brand-primary"
placeholder="Mínimo de 6 caracteres"
minLength={6}
required
/> />
</label>
)}
{notice && (
<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 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 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}
</button>
</div>
)}
</div>
</div>
</div> </div>
)} )}
</div> </div>
{editingUser && ( <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">
<label className="flex cursor-pointer items-center justify-between rounded-lg border border-dark-border bg-dark-input px-3 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>
</div>
<input
type="checkbox"
checked={isActive}
onChange={(event) => setIsActive(event.target.checked)}
className="h-5 w-5 cursor-pointer accent-brand-primary"
/>
</label>
)}
{notice && (
<div className={`rounded-lg border px-3 py-3 text-sm ${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">
{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"
>
<Copy className="h-3.5 w-3.5" />
{copyLabel}
</button>
</div>
)}
</div>
</div>
</div>
)}
<div className="flex flex-col-reverse gap-2 pt-2 sm:flex-row sm:justify-end">
<button <button
type="button" type="button"
onClick={closeModal} onClick={closeModal}
disabled={isSubmitting} 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 Cancelar
</button> </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" 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" />} {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> </button>
</div> </div>
</form> </form>
@@ -570,19 +698,24 @@ const AdminUsers = () => {
{userToDelete && ( {userToDelete && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 px-4 py-6"> <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="w-full max-w-md overflow-hidden rounded-2xl border border-dark-border bg-dark-card shadow-2xl">
<div className="border-b border-dark-border px-5 py-4"> <div className="flex items-start gap-3 border-b border-dark-border px-5 py-4">
<h2 className="text-lg font-bold text-dark-text">Excluir usuário</h2> <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">
<p className="mt-1 text-sm text-dark-muted">Esta ação remove o acesso imediatamente.</p> <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 leading-5 text-dark-muted">Esta ação remove o acesso imediatamente.</p>
</div>
</div> </div>
<div className="space-y-4 p-5"> <div className="space-y-4 p-5">
<div className="rounded-lg border border-dark-border bg-dark-input p-4"> <div className="rounded-xl border border-dark-border bg-dark-input p-4">
<p className="font-semibold text-dark-text">{userToDelete.name}</p> <p className="font-bold text-dark-text">{userToDelete.name}</p>
<p className="mt-1 text-sm text-dark-muted">{userToDelete.email}</p> <p className="mt-1 text-sm font-semibold text-dark-muted">{userToDelete.email}</p>
</div> </div>
{notice?.tone === 'error' && ( {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} {notice.text}
</div> </div>
)} )}
@@ -596,7 +729,7 @@ const AdminUsers = () => {
setNotice(null); setNotice(null);
}} }}
disabled={isSubmitting} 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 Cancelar
</button> </button>

View File

@@ -7,7 +7,7 @@ import ProductColorBadge from '../components/ProductColorBadge';
import RefreshStatus from '../components/RefreshStatus'; import RefreshStatus from '../components/RefreshStatus';
import { buildCuttingSkuConfigPath } from '../catalogLinks'; import { buildCuttingSkuConfigPath } from '../catalogLinks';
import { CUT_FAMILY_RULES, buildCutPlan, buildOpenProductionByProductId, type CutFamilyKey, type CutIssue, type CutPlanSkuRow, type CutProductOverride } from '../analytics/cutting'; import { CUT_FAMILY_RULES, buildCutPlan, buildOpenProductionByProductId, type CutFamilyKey, type CutIssue, type CutPlanSkuRow, type CutProductOverride } from '../analytics/cutting';
import { exportToCSV, fetchCuttingSettings, fetchProductAnalytics, fetchProductionOrders, saveCuttingSettings } from '../dataService'; import { createProductionOrders, exportToCSV, fetchCuttingSettings, fetchProductAnalytics, fetchProductionOrders, saveCuttingSettings } from '../dataService';
import type { CuttingSettings, DateRange, ProductAnalyticsItem, ProductionOrderItem } from '../types'; import type { CuttingSettings, DateRange, ProductAnalyticsItem, ProductionOrderItem } from '../types';
type CutFilter = 'need' | 'all' | 'issues' | 'covered'; type CutFilter = 'need' | 'all' | 'issues' | 'covered';
@@ -16,6 +16,7 @@ type SaveStatus = 'idle' | 'saving' | 'saved' | 'error';
type CutProductIssue = Exclude<CutIssue, 'missing_yield_rule'>; type CutProductIssue = Exclude<CutIssue, 'missing_yield_rule'>;
type CorrectionIssueFilter = CutProductIssue | 'all'; type CorrectionIssueFilter = CutProductIssue | 'all';
type SettingsSection = 'rules' | 'corrections'; type SettingsSection = 'rules' | 'corrections';
type CuttingView = 'plan' | 'families' | 'issues';
const SETTINGS_STORAGE_KEY = 'nexstar_cutting_settings'; const SETTINGS_STORAGE_KEY = 'nexstar_cutting_settings';
const coverageTargetOptions = [7, 15, 30, 60]; const coverageTargetOptions = [7, 15, 30, 60];
@@ -65,6 +66,13 @@ const formatNumber = (value: number, maximumFractionDigits = 0) => (
new Intl.NumberFormat('pt-BR', { maximumFractionDigits }).format(value) new Intl.NumberFormat('pt-BR', { maximumFractionDigits }).format(value)
); );
const formatDateKey = (date: Date) => {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
};
const formatDays = (value: number | null) => { const formatDays = (value: number | null) => {
if (value === null) return '-'; if (value === null) return '-';
if (value > 999) return '999+ dias'; if (value > 999) return '999+ dias';
@@ -135,6 +143,10 @@ const Cutting = () => {
const [correctionPage, setCorrectionPage] = useState(1); const [correctionPage, setCorrectionPage] = useState(1);
const [currentPage, setCurrentPage] = useState(1); const [currentPage, setCurrentPage] = useState(1);
const [itemsPerPage, setItemsPerPage] = useState(10); const [itemsPerPage, setItemsPerPage] = useState(10);
const [isGeneratingOrders, setIsGeneratingOrders] = useState(false);
const [isOrderPreviewOpen, setIsOrderPreviewOpen] = useState(false);
const [activeCuttingView, setActiveCuttingView] = useState<CuttingView>('plan');
const [generationMessage, setGenerationMessage] = useState('');
const cutConfigSection = searchParams.get('config'); const cutConfigSection = searchParams.get('config');
const targetCorrectionSku = (searchParams.get('sku') || '').trim(); const targetCorrectionSku = (searchParams.get('sku') || '').trim();
@@ -241,6 +253,14 @@ const Cutting = () => {
}); });
}, [cutFilter, cutPlan.rows, familyFilter, searchTerm, sortBy]); }, [cutFilter, cutPlan.rows, familyFilter, searchTerm, sortBy]);
const rowsAvailableForOrder = useMemo(() => (
filteredRows.filter(row => row.suggestedCutQuantity > 0)
), [filteredRows]);
const orderPreviewTotalUnits = useMemo(() => (
rowsAvailableForOrder.reduce((total, row) => total + row.suggestedCutQuantity, 0)
), [rowsAvailableForOrder]);
const totalPages = Math.ceil(filteredRows.length / itemsPerPage); const totalPages = Math.ceil(filteredRows.length / itemsPerPage);
const safeCurrentPage = Math.min(currentPage, totalPages || 1); const safeCurrentPage = Math.min(currentPage, totalPages || 1);
const startIndex = (safeCurrentPage - 1) * itemsPerPage; const startIndex = (safeCurrentPage - 1) * itemsPerPage;
@@ -289,11 +309,15 @@ const Cutting = () => {
}, [correctionItemsPerPage, correctionRows, cutConfigSection, targetCorrectionSku]); }, [correctionItemsPerPage, correctionRows, cutConfigSection, targetCorrectionSku]);
useEffect(() => { useEffect(() => {
if (!isSettingsOpen) return undefined; if (!isSettingsOpen && !isOrderPreviewOpen) return undefined;
const closeOnEscape = (event: KeyboardEvent) => { const closeOnEscape = (event: KeyboardEvent) => {
if (event.key === 'Escape') { if (event.key === 'Escape') {
setIsSettingsOpen(false); if (isOrderPreviewOpen) {
setIsOrderPreviewOpen(false);
} else {
setIsSettingsOpen(false);
}
} }
}; };
@@ -304,7 +328,7 @@ const Cutting = () => {
document.body.style.overflow = previousOverflow; document.body.style.overflow = previousOverflow;
document.removeEventListener('keydown', closeOnEscape); document.removeEventListener('keydown', closeOnEscape);
}; };
}, [isSettingsOpen]); }, [isOrderPreviewOpen, isSettingsOpen]);
const updateFamilyYield = (familyKey: CutFamilyKey, value: string) => { const updateFamilyYield = (familyKey: CutFamilyKey, value: string) => {
const parsedValue = Number(value); const parsedValue = Number(value);
@@ -400,6 +424,71 @@ const Cutting = () => {
})), `plano_corte_${new Date().toISOString().split('T')[0]}.csv`); })), `plano_corte_${new Date().toISOString().split('T')[0]}.csv`);
}; };
const openProductionOrderPreview = () => {
if (!rowsAvailableForOrder.length) {
setGenerationMessage('Não há necessidade de corte no filtro atual.');
return;
}
setGenerationMessage('');
setIsOrderPreviewOpen(true);
};
const generateProductionOrders = async () => {
if (!rowsAvailableForOrder.length) {
setGenerationMessage('Não há necessidade de corte no filtro atual.');
return;
}
setIsGeneratingOrders(true);
setGenerationMessage('');
const issueDate = formatDateKey(new Date());
const orderReference = `Plano de corte ${formatDateKey(dateRange.start)} a ${formatDateKey(dateRange.end)} · ${targetCoverageDays} dias`;
try {
const result = await createProductionOrders(rowsAvailableForOrder.map(row => ({
status: 'open',
orderReference,
issueDate,
productSku: row.id,
productDescription: row.name,
quantity: row.suggestedCutQuantity,
unit: 'UN',
integrationStatus: 'Local',
markers: [
{ label: row.family.materialLabel, color: '#38bdf8' },
...(row.color ? [{ label: row.color, color: '#52DFA0' }] : []),
{ label: 'Material pendente', color: '#facc15' },
...(row.issues.length ? [{ label: 'Dados pendentes', color: '#f59e0b' }] : [])
],
metadata: {
source: 'cut_plan',
familyKey: row.family.key,
familyLabel: row.family.label,
materialLabel: row.family.materialLabel,
color: row.color,
size: row.size,
targetCoverageDays,
projectedDemand: row.projectedDemand,
stock: row.stock,
openProductionQuantity: row.openProductionQuantity,
availableQuantity: row.availableQuantity,
issues: row.issues
}
})));
const refreshedOrders = await fetchProductionOrders(allProductionOrdersRange, undefined, { force: true });
setProductionOrders(refreshedOrders.orders);
setGenerationMessage(`${result.created.length} OPs criadas${result.skipped.length ? ` · ${result.skipped.length} já existiam abertas` : ''}.`);
setIsOrderPreviewOpen(false);
} catch (error) {
setGenerationMessage(error instanceof Error ? error.message : 'Não foi possível gerar as ordens de produção.');
} finally {
setIsGeneratingOrders(false);
}
};
const renderIssueBadge = (row: CutPlanSkuRow) => { const renderIssueBadge = (row: CutPlanSkuRow) => {
if (!row.issues.length) { if (!row.issues.length) {
return <span className="text-xs font-bold text-emerald-300">OK</span>; return <span className="text-xs font-bold text-emerald-300">OK</span>;
@@ -444,6 +533,17 @@ const Cutting = () => {
}} }}
/> />
<button
type="button"
onClick={openProductionOrderPreview}
disabled={isGeneratingOrders || !rowsAvailableForOrder.length}
className="flex items-center justify-center gap-2 rounded-xl border border-brand-primary/30 bg-brand-primary/15 px-4 py-2.5 text-sm font-medium text-brand-primary shadow-sm transition-colors hover:border-brand-primary disabled:cursor-not-allowed disabled:opacity-50 cursor-pointer"
title="Revisar ordens locais antes de gerar"
>
<ClipboardList size={16} />
<span className="hidden sm:inline">{isGeneratingOrders ? 'Gerando' : 'Prévia OPs'}</span>
</button>
<button <button
type="button" type="button"
onClick={exportRows} onClick={exportRows}
@@ -466,8 +566,136 @@ const Cutting = () => {
</div> </div>
</div> </div>
{generationMessage && (
<div className="rounded-xl border border-dark-border bg-dark-card px-4 py-3 text-sm font-bold text-dark-muted">
{generationMessage}
</div>
)}
<RefreshStatus isRefreshing={isRefreshing} /> <RefreshStatus isRefreshing={isRefreshing} />
{isOrderPreviewOpen && (
<div
className="fixed inset-0 z-50 flex h-dvh items-center justify-center overflow-hidden bg-zinc-950/80 p-4 backdrop-blur-md dark:bg-black/75"
role="dialog"
aria-modal="true"
aria-label="Prévia de ordens de produção"
onClick={() => {
if (!isGeneratingOrders) setIsOrderPreviewOpen(false);
}}
>
<div
className="flex max-h-[90dvh] w-full max-w-6xl flex-col overflow-hidden rounded-2xl border border-dark-border bg-dark-card shadow-2xl"
onClick={(event) => event.stopPropagation()}
>
<div className="flex flex-col gap-4 border-b border-dark-border p-5 md:flex-row md:items-start md:justify-between">
<div>
<h2 className="text-lg font-bold text-dark-text">Prévia das OPs</h2>
<p className="mt-1 text-sm font-semibold text-dark-muted">
Serão criadas ordens locais para as necessidades do filtro atual.
</p>
</div>
<button
type="button"
onClick={() => setIsOrderPreviewOpen(false)}
disabled={isGeneratingOrders}
className="inline-flex h-9 w-9 items-center justify-center rounded-xl border border-dark-border bg-dark-input text-dark-muted transition-colors hover:border-brand-primary hover:text-dark-text disabled:cursor-not-allowed disabled:opacity-50 cursor-pointer"
title="Fechar"
aria-label="Fechar prévia"
>
<X className="h-4 w-4" />
</button>
</div>
<div className="grid grid-cols-1 gap-3 border-b border-dark-border p-4 md:grid-cols-3">
<div className="rounded-xl border border-dark-border bg-dark-input/60 p-3">
<p className="text-[10px] font-bold uppercase tracking-widest text-dark-muted">OPs previstas</p>
<p className="mt-2 text-2xl font-bold text-dark-text">{formatNumber(rowsAvailableForOrder.length)}</p>
</div>
<div className="rounded-xl border border-dark-border bg-dark-input/60 p-3">
<p className="text-[10px] font-bold uppercase tracking-widest text-dark-muted">Unidades totais</p>
<p className="mt-2 text-2xl font-bold text-red-300">{formatNumber(orderPreviewTotalUnits)} un.</p>
</div>
<div className="rounded-xl border border-dark-border bg-dark-input/60 p-3">
<p className="text-[10px] font-bold uppercase tracking-widest text-dark-muted">Cobertura alvo</p>
<p className="mt-2 text-2xl font-bold text-brand-primary">{formatNumber(targetCoverageDays)} dias</p>
</div>
</div>
<div className="overflow-auto">
<table className="w-full min-w-[980px] text-left text-sm">
<thead className="border-b border-dark-border bg-dark-header text-dark-muted">
<tr>
<th className="px-4 py-3 text-[10px] font-bold uppercase tracking-wider">SKU</th>
<th className="px-4 py-3 text-[10px] font-bold uppercase tracking-wider">Produto</th>
<th className="px-4 py-3 text-[10px] font-bold uppercase tracking-wider">Família</th>
<th className="px-4 py-3 text-[10px] font-bold uppercase tracking-wider">Variação</th>
<th className="px-4 py-3 text-[10px] font-bold uppercase tracking-wider">Necessidade</th>
<th className="px-4 py-3 text-[10px] font-bold uppercase tracking-wider">Dados</th>
</tr>
</thead>
<tbody className="divide-y divide-dark-border">
{rowsAvailableForOrder.map(row => (
<tr key={row.id} className="hover:bg-dark-input/50">
<td className="px-4 py-3 font-mono text-[11px] text-dark-muted">#{row.id}</td>
<td className="max-w-0 px-4 py-3">
<div className="truncate font-bold text-dark-text" title={row.name}>{row.name}</div>
<div className="mt-1 text-[10px] font-semibold text-dark-muted">
Média: {formatNumber(row.dailySales, 2)} un./dia · Estoque: {formatNumber(row.stock)} un.
</div>
</td>
<td className="px-4 py-3">
<span className={`inline-flex whitespace-nowrap rounded-full border px-2.5 py-1 text-xs font-bold ${familyStyles[row.family.key]}`}>
{row.family.materialLabel}
</span>
</td>
<td className="px-4 py-3">
<div className="flex min-w-0 items-center gap-1.5">
<ProductColorBadge label={row.color} className="max-w-[110px] px-2" />
<span className="inline-flex items-center gap-1 rounded-full border border-emerald-400/25 bg-emerald-400/10 px-2 py-1 text-xs font-bold text-emerald-300">
<Ruler className="h-3 w-3" />
{row.size || '-'}
</span>
</div>
</td>
<td className="px-4 py-3 font-bold whitespace-nowrap text-red-300">
{formatNumber(row.suggestedCutQuantity)} un.
</td>
<td className="px-4 py-3">{renderIssueBadge(row)}</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="flex flex-col gap-3 border-t border-dark-border p-4 sm:flex-row sm:items-center sm:justify-between">
<p className="text-xs font-semibold text-dark-muted">
OPs abertas existentes para o mesmo plano e SKU serão ignoradas.
</p>
<div className="flex flex-col gap-2 sm:flex-row sm:justify-end">
<button
type="button"
onClick={() => setIsOrderPreviewOpen(false)}
disabled={isGeneratingOrders}
className="inline-flex items-center justify-center rounded-xl border border-dark-border bg-dark-input px-4 py-2 text-sm font-bold text-dark-text transition-colors hover:border-brand-primary disabled:cursor-not-allowed disabled:opacity-50 cursor-pointer"
>
Cancelar
</button>
<button
type="button"
onClick={() => void generateProductionOrders()}
disabled={isGeneratingOrders}
className="inline-flex items-center justify-center gap-2 rounded-xl border border-brand-primary/30 bg-brand-primary/15 px-4 py-2 text-sm font-bold text-brand-primary transition-colors hover:border-brand-primary disabled:cursor-not-allowed disabled:opacity-50 cursor-pointer"
>
<ClipboardList className="h-4 w-4" />
{isGeneratingOrders ? 'Gerando OPs' : 'Confirmar geração'}
</button>
</div>
</div>
</div>
</div>
)}
{isSettingsOpen && ( {isSettingsOpen && (
<div <div
className="fixed inset-0 z-50 flex h-dvh justify-end overflow-hidden bg-zinc-950/80 backdrop-blur-md dark:bg-black/75" className="fixed inset-0 z-50 flex h-dvh justify-end overflow-hidden bg-zinc-950/80 backdrop-blur-md dark:bg-black/75"
@@ -753,7 +981,59 @@ const Cutting = () => {
</div> </div>
</div> </div>
{!!issueSummaries.length && ( <div className="rounded-2xl border border-dark-border bg-dark-card p-2 shadow-sm">
<div className="grid grid-cols-1 gap-2 md:grid-cols-3">
<button
type="button"
onClick={() => setActiveCuttingView('plan')}
aria-pressed={activeCuttingView === 'plan'}
className={`flex items-center justify-between gap-3 rounded-xl border px-4 py-3 text-left transition-colors cursor-pointer ${activeCuttingView === 'plan' ? 'border-brand-primary/40 bg-brand-primary/15 text-brand-primary' : 'border-transparent text-dark-muted hover:bg-dark-input hover:text-dark-text'}`}
>
<span className="flex items-center gap-3">
<Scissors className="h-4 w-4" />
<span>
<span className="block text-sm font-bold">Plano</span>
<span className="block text-xs font-semibold opacity-80">SKUs para cortar</span>
</span>
</span>
<span className="text-sm font-bold">{formatNumber(rowsAvailableForOrder.length)}</span>
</button>
<button
type="button"
onClick={() => setActiveCuttingView('families')}
aria-pressed={activeCuttingView === 'families'}
className={`flex items-center justify-between gap-3 rounded-xl border px-4 py-3 text-left transition-colors cursor-pointer ${activeCuttingView === 'families' ? 'border-brand-primary/40 bg-brand-primary/15 text-brand-primary' : 'border-transparent text-dark-muted hover:bg-dark-input hover:text-dark-text'}`}
>
<span className="flex items-center gap-3">
<Layers3 className="h-4 w-4" />
<span>
<span className="block text-sm font-bold">Famílias</span>
<span className="block text-xs font-semibold opacity-80">Resumo de corte</span>
</span>
</span>
<span className="text-sm font-bold">{formatNumber(cutPlan.familySummaries.length)}</span>
</button>
<button
type="button"
onClick={() => setActiveCuttingView('issues')}
aria-pressed={activeCuttingView === 'issues'}
className={`flex items-center justify-between gap-3 rounded-xl border px-4 py-3 text-left transition-colors cursor-pointer ${activeCuttingView === 'issues' ? 'border-amber-400/40 bg-amber-400/10 text-amber-300' : 'border-transparent text-dark-muted hover:bg-dark-input hover:text-dark-text'}`}
>
<span className="flex items-center gap-3">
<AlertTriangle className="h-4 w-4" />
<span>
<span className="block text-sm font-bold">Pendências</span>
<span className="block text-xs font-semibold opacity-80">Dados a revisar</span>
</span>
</span>
<span className="text-sm font-bold">{formatNumber(cutPlan.summary.rowsWithIssues)}</span>
</button>
</div>
</div>
{activeCuttingView === 'issues' && (
<div className="rounded-2xl border border-dark-border bg-dark-card p-4 shadow-sm"> <div className="rounded-2xl border border-dark-border bg-dark-card p-4 shadow-sm">
<div className="mb-3 flex flex-col gap-3 md:flex-row md:items-center md:justify-between"> <div className="mb-3 flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div> <div>
@@ -776,26 +1056,45 @@ const Cutting = () => {
<Settings2 className="h-4 w-4 text-brand-primary" /> <Settings2 className="h-4 w-4 text-brand-primary" />
Corrigir Corrigir
</button> </button>
<button
type="button"
onClick={() => {
setCutFilter('issues');
setCurrentPage(1);
setActiveCuttingView('plan');
}}
className="inline-flex items-center justify-center gap-2 rounded-xl border border-dark-border bg-dark-input px-3 py-2 text-xs font-bold text-dark-text transition-colors hover:border-brand-primary cursor-pointer"
>
<Search className="h-4 w-4 text-brand-primary" />
Ver SKUs
</button>
</div> </div>
</div> </div>
<div className="grid grid-cols-1 gap-3 md:grid-cols-4"> {issueSummaries.length ? (
{issueSummaries.map(summary => ( <div className="grid grid-cols-1 gap-3 md:grid-cols-4">
<div {issueSummaries.map(summary => (
key={summary.issue} <div
className="rounded-xl border border-dark-border bg-dark-input/60 p-3" key={summary.issue}
> className="rounded-xl border border-dark-border bg-dark-input/60 p-3"
<div className="flex items-center justify-between gap-3"> >
<span className="text-xs font-bold text-dark-text">{issueLabels[summary.issue]}</span> <div className="flex items-center justify-between gap-3">
<span className="text-sm font-bold text-amber-300">{formatNumber(summary.count)}</span> <span className="text-xs font-bold text-dark-text">{issueLabels[summary.issue]}</span>
<span className="text-sm font-bold text-amber-300">{formatNumber(summary.count)}</span>
</div>
<p className="mt-1 line-clamp-2 text-[11px] font-medium leading-relaxed text-dark-muted">{issueHelp[summary.issue]}</p>
</div> </div>
<p className="mt-1 line-clamp-2 text-[11px] font-medium leading-relaxed text-dark-muted">{issueHelp[summary.issue]}</p> ))}
</div> </div>
))} ) : (
</div> <div className="rounded-xl border border-dark-border bg-dark-input/60 px-4 py-8 text-center">
<p className="text-sm font-bold text-dark-text">Nenhuma pendência no plano atual.</p>
<p className="mt-1 text-xs font-semibold text-dark-muted">Os SKUs com necessidade têm os dados mínimos para o cálculo.</p>
</div>
)}
</div> </div>
)} )}
{!!cutPlan.familySummaries.length && ( {activeCuttingView === 'families' && (
<div className="rounded-2xl border border-dark-border bg-dark-card p-4 shadow-sm"> <div className="rounded-2xl border border-dark-border bg-dark-card p-4 shadow-sm">
<div className="mb-3 flex items-center justify-between gap-3"> <div className="mb-3 flex items-center justify-between gap-3">
<div> <div>
@@ -804,31 +1103,40 @@ const Cutting = () => {
</div> </div>
<Scissors className="h-5 w-5 text-dark-muted" /> <Scissors className="h-5 w-5 text-dark-muted" />
</div> </div>
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 2xl:grid-cols-5"> {cutPlan.familySummaries.length ? (
{cutPlan.familySummaries.map(summary => ( <div className="grid grid-cols-1 gap-3 md:grid-cols-2 2xl:grid-cols-5">
<div {cutPlan.familySummaries.map(summary => (
key={summary.family.key} <div
className="rounded-xl border border-dark-border bg-dark-input/50 p-3" key={summary.family.key}
> className="rounded-xl border border-dark-border bg-dark-input/50 p-3"
<div className="flex items-start justify-between gap-3"> >
<div className="min-w-0"> <div className="flex items-start justify-between gap-3">
<span className={`inline-flex rounded-full border px-2.5 py-1 text-[11px] font-bold ${familyStyles[summary.family.key]}`}> <div className="min-w-0">
{summary.family.materialLabel} <span className={`inline-flex rounded-full border px-2.5 py-1 text-[11px] font-bold ${familyStyles[summary.family.key]}`}>
</span> {summary.family.materialLabel}
<h3 className="mt-2 truncate text-sm font-bold text-dark-text">{summary.family.label}</h3> </span>
<h3 className="mt-2 truncate text-sm font-bold text-dark-text">{summary.family.label}</h3>
</div>
<span className="text-[11px] font-bold text-dark-muted">{formatNumber(summary.skuCount)} SKUs</span>
</div> </div>
<span className="text-[11px] font-bold text-dark-muted">{formatNumber(summary.skuCount)} SKUs</span> <p className="mt-3 text-2xl font-bold text-dark-text">{formatNumber(summary.suggestedCutQuantity)} un.</p>
<p className="mt-1 text-xs font-semibold text-dark-muted">
{formatNumber(summary.colorCount)} cores · {summary.estimatedRolls === null ? 'sem rendimento' : `${formatNumber(summary.estimatedRolls)} rolos`}
</p>
</div> </div>
<p className="mt-3 text-2xl font-bold text-dark-text">{formatNumber(summary.suggestedCutQuantity)} un.</p> ))}
<p className="mt-1 text-xs font-semibold text-dark-muted"> </div>
{formatNumber(summary.colorCount)} cores · {summary.estimatedRolls === null ? 'sem rendimento' : `${formatNumber(summary.estimatedRolls)} rolos`} ) : (
</p> <div className="rounded-xl border border-dark-border bg-dark-input/60 px-4 py-8 text-center">
</div> <p className="text-sm font-bold text-dark-text">Nenhuma família com necessidade no período.</p>
))} <p className="mt-1 text-xs font-semibold text-dark-muted">Ajuste o período ou a cobertura alvo para revisar outro cenário.</p>
</div> </div>
)}
</div> </div>
)} )}
{activeCuttingView === 'plan' && (
<>
<div className="grid grid-cols-1 gap-3 rounded-2xl border border-dark-border bg-dark-card p-4 shadow-sm xl:grid-cols-[1fr_170px_170px_170px_190px]"> <div className="grid grid-cols-1 gap-3 rounded-2xl border border-dark-border bg-dark-card p-4 shadow-sm xl:grid-cols-[1fr_170px_170px_170px_190px]">
<div className="relative"> <div className="relative">
<Search className="absolute left-3 top-1/2 h-5 w-5 -translate-y-1/2 text-zinc-400 dark:text-dark-muted" /> <Search className="absolute left-3 top-1/2 h-5 w-5 -translate-y-1/2 text-zinc-400 dark:text-dark-muted" />
@@ -1025,6 +1333,8 @@ const Cutting = () => {
/> />
</div> </div>
)} )}
</>
)}
</div> </div>
); );
}; };

View File

@@ -303,6 +303,7 @@ const Dashboard = () => {
const [focusedSellerId, setFocusedSellerId] = useState<string | null>(null); const [focusedSellerId, setFocusedSellerId] = useState<string | null>(null);
const [sellerMetric, setSellerMetric] = useState<SellerMetricKey>('revenue'); const [sellerMetric, setSellerMetric] = useState<SellerMetricKey>('revenue');
const [selectedSellerBucket, setSelectedSellerBucket] = useState<string | null>(null); const [selectedSellerBucket, setSelectedSellerBucket] = useState<string | null>(null);
const [showSellerTrend, setShowSellerTrend] = useState(false);
const loadDashboardMetrics = useCallback(async (range: DateRange, options?: { force?: boolean }) => { const loadDashboardMetrics = useCallback(async (range: DateRange, options?: { force?: boolean }) => {
setIsMetricsLoading(true); setIsMetricsLoading(true);
@@ -603,13 +604,28 @@ const Dashboard = () => {
: sellerTimeSeries.isFallback : sellerTimeSeries.isFallback
? sellerMetricConfig.fallbackDescription ? sellerMetricConfig.fallbackDescription
: sellerTimeSeries.dateBucket === 'day' : sellerTimeSeries.dateBucket === 'day'
? `${sellerMetricConfig.trendDescription} Média móvel de 7 dias.` ? sellerMetricConfig.trendDescription
: sellerTimeSeries.dateBucket === 'week' : sellerTimeSeries.dateBucket === 'week'
? 'Evolução agrupada por semana. Média móvel de 4 semanas.' ? 'Evolução agrupada por semana no período selecionado.'
: 'Evolução agrupada por mês. Média móvel de 3 meses.'} : 'Evolução agrupada por mês no período selecionado.'}
</p> </p>
</div> </div>
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
{!sellerTimeSeries.isHourly && !sellerTimeSeries.isFallback && sellerTimeSeries.movingAverageWindow > 1 && (
<button
type="button"
onClick={() => setShowSellerTrend(current => !current)}
aria-pressed={showSellerTrend}
className={`h-9 cursor-pointer rounded-lg border px-3 text-xs font-bold transition-colors ${
showSellerTrend
? 'border-brand-primary bg-brand-primary/10 text-brand-primary'
: 'border-dark-border bg-dark-input text-dark-muted hover:text-dark-text'
}`}
title="Mostrar ou esconder média móvel"
>
Tendência
</button>
)}
<div className="inline-flex rounded-xl border border-dark-border bg-dark-input p-1"> <div className="inline-flex rounded-xl border border-dark-border bg-dark-input p-1">
{sellerMetricOptions.map(option => ( {sellerMetricOptions.map(option => (
<button <button
@@ -684,7 +700,7 @@ const Dashboard = () => {
content={<SellerMetricTooltip focusedSellerId={effectiveFocusedSellerId} sellers={sellerTimeSeries.sellers} metricConfig={sellerMetricConfig} isHourly={sellerTimeSeries.isHourly} dateBucket={sellerTimeSeries.dateBucket} />} content={<SellerMetricTooltip focusedSellerId={effectiveFocusedSellerId} sellers={sellerTimeSeries.sellers} metricConfig={sellerMetricConfig} isHourly={sellerTimeSeries.isHourly} dateBucket={sellerTimeSeries.dateBucket} />}
cursor={{ stroke: CHART_AXIS_COLOR, strokeDasharray: '4 4' }} cursor={{ stroke: CHART_AXIS_COLOR, strokeDasharray: '4 4' }}
/> />
{!sellerTimeSeries.isHourly && !sellerTimeSeries.isFallback && sellerTimeSeries.movingAverageWindow > 1 && ( {showSellerTrend && !sellerTimeSeries.isHourly && !sellerTimeSeries.isFallback && sellerTimeSeries.movingAverageWindow > 1 && (
<Line <Line
type="monotone" type="monotone"
dataKey="movingAverage" dataKey="movingAverage"

View File

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

View File

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

View File

@@ -1,15 +1,19 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useParams, Link, useOutletContext } from 'react-router-dom'; 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 { AreaChart, Area, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
import BackButton from '../components/BackButton'; import BackButton from '../components/BackButton';
import DateRangePicker from '../components/DateRangePicker'; import DateRangePicker from '../components/DateRangePicker';
import SkuPlanningModal from '../components/SkuPlanningModal';
import ProductTypeBadge from '../components/ProductTypeBadge';
import RefreshStatus from '../components/RefreshStatus'; import RefreshStatus from '../components/RefreshStatus';
import type { DateRange, ProductDetailsAnalytics } from '../types'; import type { CutProductOverride, CuttingSettings, DateRange, ProductComposition, ProductDetailsAnalytics } from '../types';
import { fetchProductDetailsAnalytics } from '../dataService'; import { fetchCuttingSettings, fetchProductComposition, fetchProductDetailsAnalytics, saveCuttingSettings } from '../dataService';
import { parseProductName } from '../productParsing'; import { parseProductName } from '../productParsing';
import { formatColorLabel } from '../displayFormatters'; import { formatColorLabel } from '../displayFormatters';
import { averageRecentValues, formatDateBucketLabel, formatDateBucketLongLabel, getAutoDateBucket, getMovingAverageWindow, getRangeDayCount, getDateBucketKey, type DateBucket } from '../chartUtils'; import { averageRecentValues, formatDateBucketLabel, formatDateBucketLongLabel, getAutoDateBucket, getMovingAverageWindow, getRangeDayCount, getDateBucketKey, type DateBucket } from '../chartUtils';
import { getProductTypeConfig, resolveProductType } from '../productClassification';
import { getPlanningStock } from '../planningStock';
const CHART_GRID_COLOR = 'var(--chart-grid)'; const CHART_GRID_COLOR = 'var(--chart-grid)';
const CHART_AXIS_COLOR = 'var(--chart-axis)'; const CHART_AXIS_COLOR = 'var(--chart-axis)';
@@ -127,9 +131,29 @@ const ProductDetails = () => {
setDateRange: (range: DateRange) => void setDateRange: (range: DateRange) => void
}>(); }>();
const [details, setDetails] = useState<ProductDetailsAnalytics | null>(null); const [details, setDetails] = useState<ProductDetailsAnalytics | null>(null);
const [composition, setComposition] = useState<ProductComposition | null>(null);
const [isCompositionLoading, setIsCompositionLoading] = useState(true);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [chartMetric, setChartMetric] = useState<ProductChartMetric>('quantity'); const [chartMetric, setChartMetric] = useState<ProductChartMetric>('quantity');
const [selectedProductBucket, setSelectedProductBucket] = useState<string | null>(null); const [selectedProductBucket, setSelectedProductBucket] = useState<string | null>(null);
const [planningSettings, setPlanningSettings] = useState<CuttingSettings>({ familyYields: {}, productOverrides: {} });
const [isPlanningModalOpen, setIsPlanningModalOpen] = useState(false);
const [isSavingPlanning, setIsSavingPlanning] = useState(false);
useEffect(() => {
let isMounted = true;
const loadPlanningSettings = async () => {
const settings = await fetchCuttingSettings();
if (isMounted) setPlanningSettings(settings);
};
void loadPlanningSettings();
return () => {
isMounted = false;
};
}, []);
useEffect(() => { useEffect(() => {
let isMounted = true; let isMounted = true;
@@ -138,17 +162,25 @@ const ProductDetails = () => {
if (!id) { if (!id) {
if (isMounted) { if (isMounted) {
setDetails(null); setDetails(null);
setComposition(null);
setIsLoading(false); setIsLoading(false);
setIsCompositionLoading(false);
} }
return; return;
} }
setIsLoading(true); setIsLoading(true);
const productDetails = await fetchProductDetailsAnalytics(id, dateRange); setIsCompositionLoading(true);
const [productDetails, productComposition] = await Promise.all([
fetchProductDetailsAnalytics(id, dateRange),
fetchProductComposition(id)
]);
if (isMounted) { if (isMounted) {
setDetails(productDetails); setDetails(productDetails);
setComposition(productComposition);
setIsLoading(false); setIsLoading(false);
setIsCompositionLoading(false);
} }
}; };
@@ -167,6 +199,25 @@ const ProductDetails = () => {
return new Intl.NumberFormat('pt-BR').format(value); 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) { if (isLoading && !details) {
return <ProductDetailsSkeleton />; return <ProductDetailsSkeleton />;
} }
@@ -181,13 +232,15 @@ const ProductDetails = () => {
} }
const { productInfo, chartData, totalSold, totalRevenue, totalOrders = 0, averageTicket = 0, variantBreakdown = [] } = details; 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 isRefreshing = isLoading && Boolean(details);
const isSingleDayRange = formatDateKey(dateRange.start) === formatDateKey(dateRange.end); const isSingleDayRange = formatDateKey(dateRange.start) === formatDateKey(dateRange.end);
const isHourlyChart = isSingleDayRange && chartData.some(point => /h$|:/.test(point.date)); const isHourlyChart = isSingleDayRange && chartData.some(point => /h$|:/.test(point.date));
const dateBucket = isHourlyChart ? 'day' : getAutoDateBucket(dateRange); const dateBucket = isHourlyChart ? 'day' : getAutoDateBucket(dateRange);
const periodDays = getRangeDayCount(dateRange); const periodDays = getRangeDayCount(dateRange);
const dailyAverageSold = totalSold / periodDays; const dailyAverageSold = totalSold / periodDays;
const projectedStockDays = dailyAverageSold > 0 ? productInfo.stock / dailyAverageSold : null; const projectedStockDays = dailyAverageSold > 0 ? getPlanningStock(productInfo.stock) / dailyAverageSold : null;
const stockActionLabel = projectedStockDays === null const stockActionLabel = projectedStockDays === null
? 'Sem venda no período' ? 'Sem venda no período'
: projectedStockDays <= 7 : projectedStockDays <= 7
@@ -292,6 +345,19 @@ const ProductDetails = () => {
<div> <div>
<div className="text-[10px] font-bold text-zinc-400 dark:text-dark-muted uppercase tracking-widest">ID: #{productInfo.id}</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> <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> </div>
</div> </div>
@@ -347,6 +413,57 @@ const ProductDetails = () => {
</div> </div>
</div> </div>
<section className="bg-white dark:bg-dark-card border border-zinc-200 dark:border-dark-border rounded-2xl p-6 shadow-sm">
<div className="mb-5">
<h3 className="text-lg font-bold text-zinc-900 dark:text-dark-text">Composição</h3>
{composition && (
<p className="mt-1 text-sm font-medium text-zinc-500 dark:text-dark-muted">
Para produzir 1 UN de {composition.finishedProductSku || productInfo.id}
</p>
)}
</div>
{isCompositionLoading ? (
<div className="flex h-24 items-center text-sm font-semibold text-zinc-500 dark:text-dark-muted">Carregando composição</div>
) : !composition ? (
<div className="flex h-24 items-center justify-center rounded-xl border border-dashed border-dark-border bg-dark-input/30 text-sm font-semibold text-zinc-500 dark:text-dark-muted">
Nenhuma composição sincronizada para este produto.
</div>
) : composition.components.length === 0 ? (
<div className="flex h-24 items-center justify-center rounded-xl border border-dashed border-dark-border bg-dark-input/30 text-sm font-semibold text-zinc-500 dark:text-dark-muted">
Esta composição não possui insumos.
</div>
) : (
<div className="overflow-x-auto rounded-xl border border-dark-border">
<table className="w-full min-w-[640px] text-left text-sm">
<thead className="bg-dark-input/60 text-[10px] font-bold uppercase tracking-widest text-dark-muted">
<tr>
<th className="px-4 py-3">Produto / insumo</th>
<th className="px-4 py-3">SKU</th>
<th className="px-4 py-3 text-right">Quantidade por unidade</th>
<th className="px-4 py-3">Unidade</th>
</tr>
</thead>
<tbody className="divide-y divide-dark-border">
{composition.components.map(component => (
<tr key={component.id} className="text-dark-text">
<td className="px-4 py-3 font-semibold">
{component.productId ? (
<Link to={`/products/${component.productId}`} className="text-brand-primary hover:underline">
{component.componentName}
</Link>
) : component.componentName}
</td>
<td className="px-4 py-3 font-mono text-xs text-dark-muted">{component.componentSku || '—'}</td>
<td className="px-4 py-3 text-right font-semibold">{formatNumber(component.quantityPerUnit)}</td>
<td className="px-4 py-3 text-dark-muted">{component.unit || '—'}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
<div className="bg-white dark:bg-dark-card border border-zinc-200 dark:border-dark-border rounded-2xl p-6 shadow-sm"> <div className="bg-white dark:bg-dark-card border border-zinc-200 dark:border-dark-border rounded-2xl p-6 shadow-sm">
<div className="mb-8 flex flex-col gap-4 md:flex-row md:items-start md:justify-between"> <div className="mb-8 flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
<div> <div>
@@ -541,6 +658,16 @@ const ProductDetails = () => {
)} )}
</div> </div>
</div> </div>
{isPlanningModalOpen && (
<SkuPlanningModal
product={productInfo}
override={planningSettings.productOverrides[productInfo.id]}
isSaving={isSavingPlanning}
onClose={() => setIsPlanningModalOpen(false)}
onSave={(override) => saveProductOverride(productInfo.id, override)}
/>
)}
</div> </div>
); );
}; };

View File

@@ -5,14 +5,17 @@ import BackButton from '../components/BackButton';
import DateRangePicker from '../components/DateRangePicker'; import DateRangePicker from '../components/DateRangePicker';
import PaginationControls from '../components/PaginationControls'; import PaginationControls from '../components/PaginationControls';
import ProductColorBadge, { ProductColorSwatch } from '../components/ProductColorBadge'; import ProductColorBadge, { ProductColorSwatch } from '../components/ProductColorBadge';
import ProductTypeBadge from '../components/ProductTypeBadge';
import RefreshStatus from '../components/RefreshStatus'; import RefreshStatus from '../components/RefreshStatus';
import { buildSkuEditPath } from '../catalogLinks'; import { buildSkuEditPath } from '../catalogLinks';
import { buildOpenProductionByProductId } from '../analytics/cutting'; import { buildOpenProductionByProductId } from '../analytics/cutting';
import { fetchProductAnalytics, fetchProductionOrders } from '../dataService'; import { fetchCuttingSettings, fetchProductAnalytics, fetchProductionOrders } from '../dataService';
import { decodeProductGroupKey, normalizeProductText, parseProductName } from '../productParsing'; import { decodeProductGroupKey, normalizeProductText, parseProductName } from '../productParsing';
import { formatColorLabel } from '../displayFormatters'; import { formatColorLabel } from '../displayFormatters';
import { getProductColor } from '../productColors'; 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 & { type VariantRow = ProductAnalyticsItem & {
color: string; color: string;
@@ -23,6 +26,7 @@ type VariantRow = ProductAnalyticsItem & {
availableQuantity: number; availableQuantity: number;
suggestedReplenishment: number; suggestedReplenishment: number;
daysOfCover: number | null; daysOfCover: number | null;
productType: ProductTypeKey;
}; };
type BreakdownRow = { type BreakdownRow = {
@@ -191,6 +195,7 @@ const ProductGroupDetails = () => {
const [products, setProducts] = useState<ProductAnalyticsItem[]>([]); const [products, setProducts] = useState<ProductAnalyticsItem[]>([]);
const [productionOrders, setProductionOrders] = useState<ProductionOrderItem[]>([]); const [productionOrders, setProductionOrders] = useState<ProductionOrderItem[]>([]);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [planningSettings, setPlanningSettings] = useState<CuttingSettings>({ familyYields: {}, productOverrides: {} });
const [currentPage, setCurrentPage] = useState(1); const [currentPage, setCurrentPage] = useState(1);
const [itemsPerPage, setItemsPerPage] = useState(20); const [itemsPerPage, setItemsPerPage] = useState(20);
@@ -204,6 +209,21 @@ const ProductGroupDetails = () => {
} }
}, [groupKey]); }, [groupKey]);
useEffect(() => {
let isMounted = true;
const loadPlanningSettings = async () => {
const settings = await fetchCuttingSettings();
if (isMounted) setPlanningSettings(settings);
};
void loadPlanningSettings();
return () => {
isMounted = false;
};
}, []);
useEffect(() => { useEffect(() => {
let isMounted = true; let isMounted = true;
@@ -240,10 +260,11 @@ const ProductGroupDetails = () => {
return products return products
.map(product => { .map(product => {
const metadata = parseProductName(product.name); const metadata = parseProductName(product.name);
const productType = resolveProductType(product.name, planningSettings.productOverrides[product.id]);
const dailySales = product.quantitySold / rangeDays; const dailySales = product.quantitySold / rangeDays;
const projectedDemand = dailySales * REPLENISHMENT_TARGET_DAYS; const projectedDemand = dailySales * REPLENISHMENT_TARGET_DAYS;
const openProductionQuantity = openProductionByProductId[product.id] || 0; const openProductionQuantity = openProductionByProductId[product.id] || 0;
const availableQuantity = product.stock + openProductionQuantity; const availableQuantity = getPlanningStock(product.stock) + openProductionQuantity;
const suggestedReplenishment = Math.max(0, Math.ceil(projectedDemand - availableQuantity)); const suggestedReplenishment = Math.max(0, Math.ceil(projectedDemand - availableQuantity));
return { return {
@@ -256,12 +277,13 @@ const ProductGroupDetails = () => {
openProductionQuantity, openProductionQuantity,
availableQuantity, availableQuantity,
suggestedReplenishment, suggestedReplenishment,
daysOfCover: dailySales > 0 ? availableQuantity / dailySales : null daysOfCover: dailySales > 0 ? availableQuantity / dailySales : null,
productType
}; };
}) })
.filter(product => normalizeProductText(product.baseName).toLowerCase() === normalizedGroupName) .filter(product => normalizeProductText(product.baseName).toLowerCase() === normalizedGroupName)
.sort((a, b) => b.quantitySold - a.quantitySold); .sort((a, b) => b.quantitySold - a.quantitySold);
}, [dateRange, groupName, openProductionByProductId, products]); }, [dateRange, groupName, openProductionByProductId, planningSettings.productOverrides, products]);
const totals = useMemo(() => { const totals = useMemo(() => {
const totalSold = groupRows.reduce((total, row) => total + row.quantitySold, 0); const totalSold = groupRows.reduce((total, row) => total + row.quantitySold, 0);
@@ -275,6 +297,7 @@ const ProductGroupDetails = () => {
const daysOfCover = dailySales > 0 ? availableQuantity / dailySales : null; const daysOfCover = dailySales > 0 ? availableQuantity / dailySales : null;
const colors = new Set(groupRows.map(row => row.color).filter(Boolean)); const colors = new Set(groupRows.map(row => row.color).filter(Boolean));
const sizes = new Set(groupRows.map(row => row.size).filter(Boolean)); const sizes = new Set(groupRows.map(row => row.size).filter(Boolean));
const productType = getDominantProductType(groupRows);
return { return {
totalSold, totalSold,
@@ -287,9 +310,11 @@ const ProductGroupDetails = () => {
suggestedReplenishment, suggestedReplenishment,
daysOfCover, daysOfCover,
colorCount: colors.size, colorCount: colors.size,
sizeCount: sizes.size sizeCount: sizes.size,
productType
}; };
}, [groupRows]); }, [groupRows]);
const productTypeConfig = getProductTypeConfig(totals.productType);
const colorBreakdown = useMemo(() => buildBreakdown(groupRows, 'color'), [groupRows]); const colorBreakdown = useMemo(() => buildBreakdown(groupRows, 'color'), [groupRows]);
const sizeBreakdown = useMemo(() => buildBreakdown(groupRows, 'size'), [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 Grupo · {formatNumber(groupRows.length)} SKUs · {formatNumber(totals.colorCount)} cores · {formatNumber(totals.sizeCount)} tamanhos
</p> </p>
<h1 className="truncate text-2xl font-bold text-zinc-900 dark:text-dark-text" title={groupName}>{groupName}</h1> <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> </div>
</div> </div>
@@ -493,7 +522,7 @@ const ProductGroupDetails = () => {
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full min-w-[1100px] table-fixed text-left text-sm"> <table className="w-full min-w-[1100px] table-fixed text-left text-sm">
<colgroup> <colgroup>
<col className="w-[120px]" /> <col className="w-[150px]" />
<col className="w-[360px]" /> <col className="w-[360px]" />
<col className="w-[120px]" /> <col className="w-[120px]" />
<col className="w-[100px]" /> <col className="w-[100px]" />
@@ -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="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"> <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="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>
<td className="px-6 py-2.5"> <td className="px-6 py-2.5">
<ProductColorBadge label={row.color} className="max-w-[8rem]" /> <ProductColorBadge label={row.color} className="max-w-[8rem]" />
@@ -533,9 +565,9 @@ const ProductGroupDetails = () => {
</span> </span>
</td> </td>
<td className="px-6 py-2.5 whitespace-nowrap"> <td className="px-6 py-2.5 whitespace-nowrap">
<div className="flex items-center gap-2"> <div className="flex min-w-0 items-center gap-2">
<Package className="h-3.5 w-3.5 text-zinc-400 dark:text-dark-muted" /> <Package className="h-3.5 w-3.5 shrink-0 text-zinc-400 dark:text-dark-muted" />
<span className="font-bold text-zinc-900 dark:text-dark-text">{formatNumber(row.quantitySold)} un.</span> <span className="min-w-0 font-bold tabular-nums text-zinc-900 dark:text-dark-text">{formatNumber(row.quantitySold)} un.</span>
</div> </div>
</td> </td>
<td className="px-6 py-2.5 whitespace-nowrap font-bold text-zinc-900 dark:text-dark-text">{formatNumber(row.stock)} un.</td> <td className="px-6 py-2.5 whitespace-nowrap font-bold text-zinc-900 dark:text-dark-text">{formatNumber(row.stock)} un.</td>

View File

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

View File

@@ -1,20 +1,24 @@
import { useEffect, useMemo, useRef, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import { Link, useOutletContext } from 'react-router-dom'; import { Link, useOutletContext } from 'react-router-dom';
import { Download, Eye, Filter, Package, PackageCheck, Pencil, Search, TrendingDown, X } from 'lucide-react'; import { Download, Eye, Filter, Package, PackageCheck, Pencil, Search, TrendingDown, X } from 'lucide-react';
import { buildSkuEditPath } from '../catalogLinks';
import DateRangePicker from '../components/DateRangePicker'; import DateRangePicker from '../components/DateRangePicker';
import PaginationControls from '../components/PaginationControls'; import PaginationControls from '../components/PaginationControls';
import SkuPlanningModal from '../components/SkuPlanningModal';
import ProductTypeBadge from '../components/ProductTypeBadge';
import RefreshStatus from '../components/RefreshStatus'; import RefreshStatus from '../components/RefreshStatus';
import type { DateRange, ProductAnalyticsItem } from '../types'; import type { CutProductOverride, CuttingSettings, DateRange, ProductAnalyticsItem } from '../types';
import { exportToCSV, fetchProductAnalytics } from '../dataService'; import { exportProductCompositions, exportToCSV, fetchCuttingSettings, fetchProductAnalytics, saveCuttingSettings } from '../dataService';
import { encodeProductGroupKey, parseProductName, sortProductSizes } from '../productParsing'; import { encodeProductGroupKey, parseProductName, sortProductSizes } from '../productParsing';
import { formatColorLabel } from '../displayFormatters'; import { formatColorLabel } from '../displayFormatters';
import { getDominantProductType, getProductTypeConfig, productTypeOptions, resolveProductType, type ProductTypeKey } from '../productClassification';
import { getPlanningStock } from '../planningStock';
type StockRisk = 'rupture' | 'critical' | 'attention' | 'monitor' | 'healthy' | 'no_sales'; type StockRisk = 'rupture' | 'critical' | 'attention' | 'monitor' | 'healthy' | 'no_sales';
type StockStatusFilter = 'all' | StockRisk; type StockStatusFilter = 'all' | StockRisk;
type StockQuantityFilter = 'all' | 'zero' | 'positive' | 'low' | 'high'; type StockQuantityFilter = 'all' | 'zero' | 'positive' | 'low' | 'high';
type SalesFilter = 'all' | 'sold' | 'not_sold'; type SalesFilter = 'all' | 'sold' | 'not_sold';
type CoverageFilter = 'all' | 'up_to_7' | 'up_to_14' | 'up_to_30' | 'over_30' | 'none'; 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 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'; type ProductViewMode = 'sku' | 'group';
@@ -33,6 +37,7 @@ type ProductRow = ProductAnalyticsItem & {
topColor: string; topColor: string;
topSize: string; topSize: string;
groupKey: string; groupKey: string;
productType: ProductTypeKey;
}; };
const riskStyles: Record<StockRisk, { label: string; className: string; dotClass: string }> = { const riskStyles: Record<StockRisk, { label: string; className: string; dotClass: string }> = {
@@ -163,15 +168,37 @@ const Products = () => {
const [stockQuantityFilter, setStockQuantityFilter] = useState<StockQuantityFilter>('all'); const [stockQuantityFilter, setStockQuantityFilter] = useState<StockQuantityFilter>('all');
const [salesFilter, setSalesFilter] = useState<SalesFilter>('all'); const [salesFilter, setSalesFilter] = useState<SalesFilter>('all');
const [coverageFilter, setCoverageFilter] = useState<CoverageFilter>('all'); const [coverageFilter, setCoverageFilter] = useState<CoverageFilter>('all');
const [productTypeFilter, setProductTypeFilter] = useState<ProductTypeFilter>('all');
const [viewMode, setViewMode] = useState<ProductViewMode>('sku'); const [viewMode, setViewMode] = useState<ProductViewMode>('sku');
const [isFilterMenuOpen, setIsFilterMenuOpen] = useState(false); const [isFilterMenuOpen, setIsFilterMenuOpen] = useState(false);
const [isExportMenuOpen, setIsExportMenuOpen] = useState(false);
const filterMenuRef = useRef<HTMLDivElement>(null); const filterMenuRef = useRef<HTMLDivElement>(null);
const exportMenuRef = useRef<HTMLDivElement>(null);
const [productAnalytics, setProductAnalytics] = useState<ProductAnalyticsItem[]>([]); const [productAnalytics, setProductAnalytics] = useState<ProductAnalyticsItem[]>([]);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [planningSettings, setPlanningSettings] = useState<CuttingSettings>({ familyYields: {}, productOverrides: {} });
const [editingProduct, setEditingProduct] = useState<ProductRow | null>(null);
const [isSavingPlanning, setIsSavingPlanning] = useState(false);
const [isExportingCompositions, setIsExportingCompositions] = useState(false);
const [currentPage, setCurrentPage] = useState(1); const [currentPage, setCurrentPage] = useState(1);
const [itemsPerPage, setItemsPerPage] = useState(10); const [itemsPerPage, setItemsPerPage] = useState(10);
useEffect(() => {
let isMounted = true;
const loadPlanningSettings = async () => {
const settings = await fetchCuttingSettings();
if (isMounted) setPlanningSettings(settings);
};
void loadPlanningSettings();
return () => {
isMounted = false;
};
}, []);
useEffect(() => { useEffect(() => {
let isMounted = true; let isMounted = true;
@@ -192,17 +219,40 @@ const Products = () => {
}; };
}, [dateRange]); }, [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(() => { useEffect(() => {
if (!isFilterMenuOpen) return; if (!isFilterMenuOpen && !isExportMenuOpen) return;
const handlePointerDown = (event: PointerEvent) => { const handlePointerDown = (event: PointerEvent) => {
if (!filterMenuRef.current?.contains(event.target as Node)) { if (!filterMenuRef.current?.contains(event.target as Node)) {
setIsFilterMenuOpen(false); setIsFilterMenuOpen(false);
} }
if (!exportMenuRef.current?.contains(event.target as Node)) {
setIsExportMenuOpen(false);
}
}; };
const handleKeyDown = (event: KeyboardEvent) => { const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') { if (event.key === 'Escape') {
setIsFilterMenuOpen(false); setIsFilterMenuOpen(false);
setIsExportMenuOpen(false);
} }
}; };
@@ -213,21 +263,23 @@ const Products = () => {
document.removeEventListener('pointerdown', handlePointerDown); document.removeEventListener('pointerdown', handlePointerDown);
document.removeEventListener('keydown', handleKeyDown); document.removeEventListener('keydown', handleKeyDown);
}; };
}, [isFilterMenuOpen]); }, [isFilterMenuOpen, isExportMenuOpen]);
const productsData = useMemo<ProductRow[]>(() => { const productsData = useMemo<ProductRow[]>(() => {
const days = getRangeDays(dateRange); const days = getRangeDays(dateRange);
const normalizedSearch = searchTerm.trim().toLowerCase(); const normalizedSearch = searchTerm.trim().toLowerCase();
const skuRows = productAnalytics.map(product => { const skuRows = productAnalytics.map(product => {
const dailySales = product.quantitySold / days; const dailySales = product.quantitySold / days;
const risk = classifyStockRisk(product.stock, dailySales); const planningStock = getPlanningStock(product.stock);
const risk = classifyStockRisk(planningStock, dailySales);
const style = riskStyles[risk]; const style = riskStyles[risk];
const metadata = parseProductName(product.name); const metadata = parseProductName(product.name);
const productType = resolveProductType(product.name, planningSettings.productOverrides[product.id]);
return { return {
...product, ...product,
dailySales, dailySales,
daysOfCover: dailySales > 0 ? product.stock / dailySales : null, daysOfCover: dailySales > 0 ? planningStock / dailySales : null,
risk, risk,
riskLabel: style.label, riskLabel: style.label,
baseName: metadata.baseName, baseName: metadata.baseName,
@@ -239,7 +291,8 @@ const Products = () => {
sizes: metadata.size ? [metadata.size] : [], sizes: metadata.size ? [metadata.size] : [],
topColor: metadata.color || '-', topColor: metadata.color || '-',
topSize: metadata.size || '-', 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 groupedRows = Array.from(groups.values()).map(group => {
const first = group[0]; const first = group[0];
const productType = getDominantProductType(group);
const quantitySold = group.reduce((total, product) => total + product.quantitySold, 0); const quantitySold = group.reduce((total, product) => total + product.quantitySold, 0);
const revenue = group.reduce((total, product) => total + product.revenue, 0); const revenue = group.reduce((total, product) => total + product.revenue, 0);
const stock = group.reduce((total, product) => total + product.stock, 0); const stock = group.reduce((total, product) => total + product.stock, 0);
const planningStock = group.reduce((total, product) => total + getPlanningStock(product.stock), 0);
const orderLineCount = group.reduce((total, product) => total + product.orderLineCount, 0); const orderLineCount = group.reduce((total, product) => total + product.orderLineCount, 0);
const dailySales = quantitySold / days; const dailySales = quantitySold / days;
const daysOfCover = dailySales > 0 ? stock / dailySales : null; const daysOfCover = dailySales > 0 ? planningStock / dailySales : null;
const risk = classifyStockRisk(stock, dailySales); const risk = classifyStockRisk(planningStock, dailySales);
const style = riskStyles[risk]; const style = riskStyles[risk];
const colorTotals = new Map<string, number>(); const colorTotals = new Map<string, number>();
const sizeTotals = new Map<string, number>(); const sizeTotals = new Map<string, number>();
@@ -297,6 +352,7 @@ const Products = () => {
sizes, sizes,
topColor, topColor,
topSize, topSize,
productType,
lastPrice: quantitySold > 0 ? revenue / quantitySold : first.lastPrice lastPrice: quantitySold > 0 ? revenue / quantitySold : first.lastPrice
}; };
}); });
@@ -309,7 +365,8 @@ const Products = () => {
product.id.toLowerCase().includes(normalizedSearch) || product.id.toLowerCase().includes(normalizedSearch) ||
product.productIds.some(id => id.toLowerCase().includes(normalizedSearch)) || product.productIds.some(id => id.toLowerCase().includes(normalizedSearch)) ||
product.colors.some(color => color.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; : activeRows;
@@ -325,6 +382,7 @@ const Products = () => {
salesFilter === 'all' || salesFilter === 'all' ||
(salesFilter === 'sold' && product.quantitySold > 0) || (salesFilter === 'sold' && product.quantitySold > 0) ||
(salesFilter === 'not_sold' && product.quantitySold === 0); (salesFilter === 'not_sold' && product.quantitySold === 0);
const matchesProductType = productTypeFilter === 'all' || product.productType === productTypeFilter;
const matchesCoverage = const matchesCoverage =
coverageFilter === 'all' || coverageFilter === 'all' ||
(coverageFilter === 'none' && product.daysOfCover === null) || (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 === 'up_to_30' && product.daysOfCover !== null && product.daysOfCover > 14 && product.daysOfCover <= 30) ||
(coverageFilter === 'over_30' && product.daysOfCover !== null && 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) => { return detailedFilteredProducts.sort((a, b) => {
@@ -356,12 +414,13 @@ const Products = () => {
return b.quantitySold - a.quantitySold; 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 = const activeFilterCount =
(stockStatusFilter === 'all' ? 0 : 1) + (stockStatusFilter === 'all' ? 0 : 1) +
(stockQuantityFilter === 'all' ? 0 : 1) + (stockQuantityFilter === 'all' ? 0 : 1) +
(salesFilter === 'all' ? 0 : 1) + (salesFilter === 'all' ? 0 : 1) +
(productTypeFilter === 'all' ? 0 : 1) +
(coverageFilter === 'all' ? 0 : 1); (coverageFilter === 'all' ? 0 : 1);
const hasActiveFilters = activeFilterCount > 0; const hasActiveFilters = activeFilterCount > 0;
@@ -369,6 +428,7 @@ const Products = () => {
setStockStatusFilter('all'); setStockStatusFilter('all');
setStockQuantityFilter('all'); setStockQuantityFilter('all');
setSalesFilter('all'); setSalesFilter('all');
setProductTypeFilter('all');
setCoverageFilter('all'); setCoverageFilter('all');
setCurrentPage(1); setCurrentPage(1);
}; };
@@ -400,33 +460,68 @@ const Products = () => {
}} }}
/> />
<button <div ref={exportMenuRef} className="relative">
onClick={() => { <button
const exportData = productsData.map(product => ({ type="button"
'Tipo': viewMode === 'group' ? 'Grupo' : 'SKU', onClick={() => setIsExportMenuOpen(current => !current)}
'ID Produto': viewMode === 'group' ? product.productIds.join(' | ') : product.id, aria-expanded={isExportMenuOpen}
'Descrição': product.name, 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"
'SKUs': product.skuCount, title="Escolher exportação"
'Cores': product.colors.join(' | '), >
'Tamanhos': product.sizes.join(' | '), <Download size={16} className="text-brand-primary" />
'Cor principal': product.topColor, <span>Exportar</span>
'Tamanho principal': product.topSize, </button>
'Status': product.riskLabel, {isExportMenuOpen && (
'Preço Atual (R$)': product.lastPrice.toFixed(2).replace('.', ','), <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">
'Total Vendido (un.)': product.quantitySold, <button
'Estoque': product.stock, type="button"
'Média Diária': product.dailySales.toFixed(2).replace('.', ','), onClick={() => {
'Cobertura': product.daysOfCover === null ? '' : product.daysOfCover.toFixed(1).replace('.', ','), const exportData = productsData.map(product => ({
'Receita Gerada (R$)': product.revenue.toFixed(2).replace('.', ',') 'Tipo': viewMode === 'group' ? 'Grupo' : 'SKU',
})); 'ID Produto': viewMode === 'group' ? product.productIds.join(' | ') : product.id,
exportToCSV(exportData, `${viewMode === 'group' ? 'grupos_produtos' : 'produtos'}_${new Date().toISOString().split('T')[0]}.csv`); 'Descrição': product.name,
}} 'SKUs': product.skuCount,
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" 'Cores': product.colors.join(' | '),
title="Exportar para CSV" 'Tamanhos': product.sizes.join(' | '),
> 'Tipo de produto': getProductTypeConfig(product.productType).label,
<Download size={16} className="text-brand-primary" /> 'Cor principal': product.topColor,
<span className="hidden sm:inline">Exportar</span> 'Tamanho principal': product.topSize,
</button> 'Status': product.riskLabel,
'Preço Atual (R$)': product.lastPrice.toFixed(2).replace('.', ','),
'Total Vendido (un.)': product.quantitySold,
'Estoque': product.stock,
'Média Diária': product.dailySales.toFixed(2).replace('.', ','),
'Cobertura': product.daysOfCover === null ? '' : product.daysOfCover.toFixed(1).replace('.', ','),
'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="w-full rounded-lg px-3 py-2 text-left text-sm font-semibold text-dark-text hover:bg-dark-input cursor-pointer"
>
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>
</div> </div>
@@ -504,6 +599,23 @@ const Products = () => {
</div> </div>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2"> <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"> <label className="block text-xs font-bold uppercase tracking-wider text-dark-muted">
Status Status
<select <select
@@ -626,7 +738,7 @@ const Products = () => {
<col className="w-[120px]" /> <col className="w-[120px]" />
<col className="w-[390px]" /> <col className="w-[390px]" />
<col className="w-[170px]" /> <col className="w-[170px]" />
<col className="w-[130px]" /> <col className="w-[150px]" />
<col className="w-[120px]" /> <col className="w-[120px]" />
<col className="w-[130px]" /> <col className="w-[130px]" />
<col className="w-[130px]" /> <col className="w-[130px]" />
@@ -657,10 +769,13 @@ const Products = () => {
</td> </td>
<td className="max-w-0 px-6 py-2.5"> <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 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">
{viewMode === 'group' <ProductTypeBadge type={product.productType} />
? `Cor principal: ${formatColorLabel(product.topColor)} · Tam. principal: ${product.topSize} · ${product.colors.length} cores · ${product.sizes.length} tamanhos` <span className="truncate text-[10px] font-medium text-zinc-400 dark:text-dark-muted">
: `Preço Atual: ${formatCurrency(product.lastPrice)}`} {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> </div>
</td> </td>
<td className="px-6 py-2.5"> <td className="px-6 py-2.5">
@@ -670,9 +785,9 @@ const Products = () => {
</span> </span>
</td> </td>
<td className="px-6 py-2.5 whitespace-nowrap"> <td className="px-6 py-2.5 whitespace-nowrap">
<div className="flex items-center gap-2"> <div className="flex min-w-0 items-center gap-2">
<Package className="w-3.5 h-3.5 text-zinc-400 dark:text-dark-muted" /> <Package className="h-3.5 w-3.5 shrink-0 text-zinc-400 dark:text-dark-muted" />
<span className="font-bold text-zinc-900 dark:text-dark-text">{formatNumber(product.quantitySold)} un.</span> <span className="min-w-0 font-bold tabular-nums text-zinc-900 dark:text-dark-text">{formatNumber(product.quantitySold)} un.</span>
</div> </div>
</td> </td>
<td className="px-6 py-2.5 font-bold text-zinc-900 dark:text-dark-text whitespace-nowrap">{formatNumber(product.stock)} un.</td> <td className="px-6 py-2.5 font-bold text-zinc-900 dark:text-dark-text whitespace-nowrap">{formatNumber(product.stock)} un.</td>
@@ -687,14 +802,15 @@ const Products = () => {
<td className="px-4 py-2.5 text-right"> <td className="px-4 py-2.5 text-right">
<div className="flex justify-end gap-2"> <div className="flex justify-end gap-2">
{viewMode === 'sku' && ( {viewMode === 'sku' && (
<Link <button
to={buildSkuEditPath({ sku: product.id, name: product.name, color: product.color, size: product.size })} 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" 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}`} title={`Editar planejamento do SKU ${product.id}`}
aria-label={`Editar SKU ${product.id}`} aria-label={`Editar planejamento do SKU ${product.id}`}
> >
<Pencil className="h-3.5 w-3.5" /> <Pencil className="h-3.5 w-3.5" />
</Link> </button>
)} )}
<Link <Link
to={viewMode === 'group' ? `/products/groups/${product.groupKey}` : `/products/${product.id}`} to={viewMode === 'group' ? `/products/groups/${product.groupKey}` : `/products/${product.id}`}
@@ -739,6 +855,16 @@ const Products = () => {
/> />
</div> </div>
)} )}
{editingProduct && (
<SkuPlanningModal
product={editingProduct}
override={planningSettings.productOverrides[editingProduct.id]}
isSaving={isSavingPlanning}
onClose={() => setEditingProduct(null)}
onSave={(override) => saveProductOverride(editingProduct.id, override)}
/>
)}
</div> </div>
); );
}; };

View File

@@ -1,6 +1,6 @@
import { useEffect, useMemo, useRef, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import { useSearchParams } from 'react-router-dom'; import { Link, useSearchParams } from 'react-router-dom';
import { Loader2, Package, RefreshCw, Ruler, Save, Tags, Trash2 } from 'lucide-react'; import { ClipboardList, Loader2, Package, RefreshCw, Ruler, Save, Tags, Trash2 } from 'lucide-react';
import { import {
deleteCatalogCategory, deleteCatalogCategory,
deleteCatalogProduct, deleteCatalogProduct,
@@ -92,6 +92,16 @@ const getAverageYield = (reference: ConsumptionReference) => {
return reference.generalYield; return reference.generalYield;
}; };
const getReferenceSourceLabel = (reference: ConsumptionReference) => (
reference.source === 'tiny_op' ? 'Tiny OP' : 'Manual'
);
const formatConsumptionPerPiece = (reference: ConsumptionReference) => {
if (!reference.consumptionQuantity) return '';
const unit = reference.consumptionUnit || 'un.';
return `${formatNumber(reference.consumptionQuantity, 4)} ${unit}/peça`;
};
const Registrations = () => { const Registrations = () => {
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const [catalog, setCatalog] = useState<CatalogSummary>(emptyCatalog); const [catalog, setCatalog] = useState<CatalogSummary>(emptyCatalog);
@@ -340,7 +350,7 @@ const Registrations = () => {
const tabItems: Array<{ key: RegistrationTab; label: string; icon: typeof Package; count: number }> = [ const tabItems: Array<{ key: RegistrationTab; label: string; icon: typeof Package; count: number }> = [
{ key: 'products', label: 'Produtos', icon: Package, count: catalog.products.length }, { key: 'products', label: 'Produtos', icon: Package, count: catalog.products.length },
{ key: 'categories', label: 'Categorias', icon: Tags, count: catalog.categories.length }, { key: 'categories', label: 'Categorias', icon: Tags, count: catalog.categories.length },
{ key: 'references', label: 'Referencia de Consumo', icon: Ruler, count: catalog.consumptionReferences.length } { key: 'references', label: 'Referência de Consumo', icon: Ruler, count: catalog.consumptionReferences.length }
]; ];
return ( return (
@@ -349,17 +359,26 @@ const Registrations = () => {
<div> <div>
<h1 className="mb-2 text-2xl font-bold text-zinc-900 dark:text-dark-text">Cadastros</h1> <h1 className="mb-2 text-2xl font-bold text-zinc-900 dark:text-dark-text">Cadastros</h1>
<p className="font-medium text-zinc-500 dark:text-dark-muted"> <p className="font-medium text-zinc-500 dark:text-dark-muted">
Base de produtos, categorias e referencias de consumo usadas pelo corte. Fonte de verdade para produtos, matérias-primas e referências de consumo.
</p> </p>
</div> </div>
<button <div className="flex flex-wrap gap-2">
type="button" <Link
onClick={() => void loadCatalog()} to="/planning-issues"
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"
> >
<RefreshCw className="h-4 w-4 text-brand-primary" /> <ClipboardList className="h-4 w-4 text-brand-primary" />
Atualizar Dados Pendentes
</button> </Link>
<button
type="button"
onClick={() => void loadCatalog()}
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>
<div className="grid grid-cols-1 gap-3 md:grid-cols-3"> <div className="grid grid-cols-1 gap-3 md:grid-cols-3">
@@ -654,6 +673,13 @@ const Registrations = () => {
<span className="rounded-full border border-dark-border bg-dark-input px-2 py-0.5 text-[10px] font-bold text-dark-muted"> <span className="rounded-full border border-dark-border bg-dark-input px-2 py-0.5 text-[10px] font-bold text-dark-muted">
{Object.keys(reference.sizeYields || {}).length ? 'Por tamanho' : 'Geral'} {Object.keys(reference.sizeYields || {}).length ? 'Por tamanho' : 'Geral'}
</span> </span>
<span className={`rounded-full border px-2 py-0.5 text-[10px] font-bold ${
reference.source === 'tiny_op'
? 'border-sky-400/30 bg-sky-400/10 text-sky-300'
: 'border-dark-border bg-dark-input text-dark-muted'
}`}>
{getReferenceSourceLabel(reference)}
</span>
</div> </div>
<h3 className="mt-1 truncate text-sm font-bold text-dark-text">{reference.productName}</h3> <h3 className="mt-1 truncate text-sm font-bold text-dark-text">{reference.productName}</h3>
<p className="mt-1 text-xs font-semibold text-dark-muted"> <p className="mt-1 text-xs font-semibold text-dark-muted">
@@ -671,10 +697,19 @@ const Registrations = () => {
</div> </div>
<div className="flex shrink-0 items-start gap-3"> <div className="flex shrink-0 items-start gap-3">
<div className="text-right"> <div className="text-right">
<div className="text-lg font-bold text-emerald-300">{formatNumber(getAverageYield(reference), 3)} /kg</div> <div className="text-lg font-bold text-emerald-300">
<div className="text-[10px] font-bold uppercase tracking-widest text-dark-muted"> {formatConsumptionPerPiece(reference) || `${formatNumber(getAverageYield(reference), 3)} pç/kg`}
{Object.keys(reference.sizeYields || {}).length ? 'media' : 'geral'}
</div> </div>
<div className="text-[10px] font-bold uppercase tracking-widest text-dark-muted">
{reference.consumptionQuantity
? 'consumo'
: Object.keys(reference.sizeYields || {}).length ? 'media' : 'geral'}
</div>
{reference.consumptionQuantity && getAverageYield(reference) ? (
<div className="mt-1 text-[11px] font-semibold text-dark-muted">
{formatNumber(getAverageYield(reference), 3)} /kg
</div>
) : null}
</div> </div>
<button type="button" onClick={() => void removeReference(reference)} className="inline-flex h-9 w-9 items-center justify-center rounded-lg border border-dark-border bg-dark-input text-dark-muted transition-colors hover:border-red-400/40 hover:text-red-300 cursor-pointer"> <button type="button" onClick={() => void removeReference(reference)} className="inline-flex h-9 w-9 items-center justify-center rounded-lg border border-dark-border bg-dark-input text-dark-muted transition-colors hover:border-red-400/40 hover:text-red-300 cursor-pointer">
<Trash2 className="h-4 w-4" /> <Trash2 className="h-4 w-4" />

View File

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

File diff suppressed because it is too large Load Diff

1
src/planningStock.ts Normal file
View File

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

View File

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

View File

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

View File

@@ -1,3 +1,5 @@
import type { ProductTypeKey } from './productClassification';
export interface OrderData { export interface OrderData {
Nome_Cliente: string; Nome_Cliente: string;
Data_Pedido: string; Data_Pedido: string;
@@ -32,6 +34,26 @@ export interface ProductionOrderMarker {
color?: string | null; color?: string | null;
} }
export interface ProductionOrderComponent {
id: number;
componentTinyId: string;
componentSku: string;
componentName: string;
quantityPerUnit: number;
totalQuantity: number;
unit: string;
}
export interface ProductionOrderStep {
id: number;
stepNumber: number | null;
name: string;
startDate: string | null;
endDate: string | null;
status: string;
color: string;
}
export interface ProductionOrderItem { export interface ProductionOrderItem {
id: number; id: number;
tinyId: string; tinyId: string;
@@ -46,7 +68,16 @@ export interface ProductionOrderItem {
quantity: number; quantity: number;
unit: string; unit: string;
integrationStatus: string; integrationStatus: string;
notes: string;
supplier: string;
lotCode: string;
rollQuantity: number | null;
fabricKg: number | null;
ribKg: number | null;
yieldPiecesPerKg: number | null;
markers: ProductionOrderMarker[]; markers: ProductionOrderMarker[];
components: ProductionOrderComponent[];
steps: ProductionOrderStep[];
createdAt: string | null; createdAt: string | null;
updatedAt: string | null; updatedAt: string | null;
} }
@@ -65,12 +96,43 @@ export interface ProductionOrderSummary {
counts: ProductionOrderCounts; counts: ProductionOrderCounts;
} }
export type ProductionOrderMarkerPayload = {
label: string;
color?: string | null;
};
export type ProductionOrderPayload = {
number?: string;
status?: ProductionOrderStatus;
orderReference?: string;
issueDate?: string | null;
expectedDate?: string | null;
productSku?: string;
productDescription: string;
quantity: number;
unit?: string;
integrationStatus?: string;
markers?: ProductionOrderMarkerPayload[];
metadata?: Record<string, unknown>;
};
export interface CreateProductionOrdersResult {
created: ProductionOrderItem[];
skipped: Array<{
productSku?: string;
productDescription?: string;
reason: string;
}>;
}
export type CutFamilyKey = 'BLCS' | 'BLOS' | 'BLMC' | 'BLPM' | 'OUTROS'; export type CutFamilyKey = 'BLCS' | 'BLOS' | 'BLMC' | 'BLPM' | 'OUTROS';
export interface CutProductOverride { export interface CutProductOverride {
familyKey?: CutFamilyKey | ''; familyKey?: CutFamilyKey | '';
color?: string; color?: string;
size?: string; size?: string;
productType?: ProductTypeKey | '';
planningNotes?: string;
} }
export interface CuttingSettings { export interface CuttingSettings {
@@ -123,6 +185,10 @@ export interface ConsumptionReference {
efficiencyPercent: number | null; efficiencyPercent: number | null;
ribGPerPiece: number | null; ribGPerPiece: number | null;
materialCostPerKg: number | null; materialCostPerKg: number | null;
consumptionQuantity: number | null;
consumptionUnit: string;
source: string;
lastProductionOrderId: number | null;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
} }
@@ -164,6 +230,8 @@ export type ConsumptionReferencePayload = {
efficiencyPercent?: number | string | null; efficiencyPercent?: number | string | null;
ribGPerPiece?: number | string | null; ribGPerPiece?: number | string | null;
materialCostPerKg?: number | string | null; materialCostPerKg?: number | string | null;
consumptionQuantity?: number | string | null;
consumptionUnit?: string | null;
}; };
export type SupplyReceiptStatus = 'pending' | 'approved'; export type SupplyReceiptStatus = 'pending' | 'approved';
@@ -242,6 +310,8 @@ export interface SupplyPurchaseNeed {
quantitySold: number; quantitySold: number;
stockQuantity: number; stockQuantity: number;
yieldPerKg?: number; yieldPerKg?: number;
consumptionQuantity?: number;
consumptionUnit?: string;
}>; }>;
} }
@@ -405,6 +475,29 @@ export interface ProductDetailsAnalytics {
}>; }>;
} }
export interface ProductCompositionComponent {
id: number;
componentTinyId: string;
componentSku: string;
componentName: string;
quantityPerUnit: number;
unit: string;
productId: string | null;
}
export interface ProductComposition {
id: number;
source: string;
externalSourceId: string;
finishedProductSku: string;
finishedProductDescription: string;
finishedProductUnit: string;
finishedTinyProductId: string;
sourceMetadata: Record<string, unknown>;
lastSyncedAt: string | null;
components: ProductCompositionComponent[];
}
export interface ClientAnalyticsItem { export interface ClientAnalyticsItem {
customerKey: string; customerKey: string;
clientToken: string; clientToken: string;

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

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