Compare commits

...

80 Commits

Author SHA1 Message Date
Cauê Faleiros
9d99f2387b perf: limit cut plan to active orders
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 53s
2026-08-28 13:14:21 -03:00
Cauê Faleiros
4122e21dc2 perf: defer cut yield recommendations
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m10s
2026-08-28 12:53:37 -03:00
Cauê Faleiros
c5edaec7d3 Reapply "feat: open administration to all users"
This reverts commit 9f4db67f4f.
2026-08-28 12:49:22 -03:00
Cauê Faleiros
9f4db67f4f Revert "feat: open administration to all users"
This reverts commit b4b21fb21b.
2026-08-28 12:47:13 -03:00
Cauê Faleiros
b4b21fb21b feat: open administration to all users
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m33s
2026-08-28 11:33:40 -03:00
Cauê Faleiros
ba136b9d78 fix: keep sessions on forbidden responses
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m32s
2026-08-28 11:07:39 -03:00
Cauê Faleiros
5a23817f4c Show synchronized composition in production orders 2026-08-28 09:39:01 -03:00
Cauê Faleiros
35f3938f1c Widen material transformation form 2026-08-28 09:36:05 -03:00
Cauê Faleiros
56ce81850b Add status tabs to production plans
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 48s
2026-08-28 09:26:02 -03:00
Cauê Faleiros
6b3f84a666 Allow production orders to return to prior stages
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m2s
2026-08-28 09:16:48 -03:00
Cauê Faleiros
bc6d53b840 Fix composition lookup by external product ID
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m1s
2026-08-28 08:22:58 -03:00
Cauê Faleiros
5e742feb60 Streamline production order workflow
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 44s
2026-08-27 20:30:15 -03:00
Cauê Faleiros
9592b2eb7d Remove contractor label from production plans
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 42s
2026-08-27 20:19:49 -03:00
Cauê Faleiros
314288147a Select checklist reviewer from users
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 40s
2026-08-27 20:15:54 -03:00
Cauê Faleiros
acf542f2d5 Paginate production plan confirmation
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 37s
2026-08-27 20:12:11 -03:00
Cauê Faleiros
7ae230ed2e Improve production plan interactions
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 41s
2026-08-27 20:07:58 -03:00
Cauê Faleiros
c77bc752ae Improve production order preview selection
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 42s
2026-08-27 20:02:51 -03:00
Cauê Faleiros
4b66e91442 Refine production plan actions and deadlines
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 46s
2026-08-27 19:53:50 -03:00
Cauê Faleiros
a938925904 Remove unsupported layer controls
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 45s
2026-08-27 19:49:57 -03:00
Cauê Faleiros
9960fb7986 Return production plans to supplies hub
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 46s
2026-08-27 19:43:09 -03:00
Cauê Faleiros
2b7c132300 Improve production plan operations 2026-08-27 19:38:08 -03:00
Cauê Faleiros
ef29365932 Fix cut plan confirmation cleanup
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m25s
2026-08-27 19:30:04 -03:00
Cauê Faleiros
64e5fefcb4 Move marker reference to production execution 2026-08-27 19:25:30 -03:00
Cauê Faleiros
d623688263 Add supplies workflow demonstration seed 2026-08-27 19:01:32 -03:00
Cauê Faleiros
e62793273b Add supplies operations guide 2026-08-27 15:39:41 -03:00
Cauê Faleiros
5d697f8f8a Consolidate production flow under supplies
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m43s
2026-08-27 14:16:45 -03:00
Cauê Faleiros
ed750ac4ce Organize quality under supplies 2026-08-27 13:43:17 -03:00
Cauê Faleiros
e8a684b0ab Link receipts to pending purchase approval
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m44s
2026-08-27 11:28:34 -03:00
Cauê Faleiros
458efa073d Add fabric hole quality indicator 2026-08-27 11:23:18 -03:00
Cauê Faleiros
ddff880215 Add pre-cut layer and marker planning 2026-08-27 11:22:12 -03:00
Cauê Faleiros
886969cce0 Record cut layers and marker references 2026-08-27 10:56:11 -03:00
Cauê Faleiros
791246c55e Add production plan release checklist 2026-08-27 10:51:41 -03:00
Cauê Faleiros
442b23dc62 Add material transformation planning 2026-08-27 10:44:49 -03:00
Cauê Faleiros
da6f3b2955 Add production quality and workstation screen 2026-08-26 16:34:25 -03:00
Cauê Faleiros
2c15719b13 Add production workstation management API 2026-08-26 16:30:36 -03:00
Cauê Faleiros
7e65feac37 Add production order defect capture 2026-08-26 16:28:59 -03:00
Cauê Faleiros
38add708ff Improve consumption reference search and pagination
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 51s
2026-08-26 16:03:52 -03:00
Cauê Faleiros
5f767a96b6 Balance consumption reference panels
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m2s
2026-08-26 15:32:00 -03:00
Cauê Faleiros
862e8e1d51 Widen consumption reference workspace
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m13s
2026-08-26 15:25:25 -03:00
Cauê Faleiros
fb243cdd20 Add searchable consumption reference selectors
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m31s
2026-08-26 14:40:07 -03:00
Cauê Faleiros
1c0025ed17 Make purchase order suppliers selectable 2026-08-26 13:40:25 -03:00
Cauê Faleiros
94d4605f80 Fix supply navigation and supplier loading
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m29s
2026-08-26 13:28:42 -03:00
Cauê Faleiros
3a0cc9ffe8 Allow updating production plan responsibility
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m13s
2026-08-25 16:42:51 -03:00
Cauê Faleiros
c259ddea29 Assign production plans to Graphs users 2026-08-25 13:40:52 -03:00
Cauê Faleiros
d14f7e315f Split production order preview into steps 2026-08-25 13:20:04 -03:00
Cauê Faleiros
cdeb3dcc59 Simplify production order preview 2026-08-25 13:07:41 -03:00
Cauê Faleiros
03b6e2fef7 Fix production order status updates 2026-08-25 12:59:54 -03:00
Cauê Faleiros
5370dffce2 Add production defect and workstation foundation 2026-08-25 11:58:43 -03:00
Cauê Faleiros
282f1e194a Fix production plan reservation migration 2026-08-25 11:56:03 -03:00
Cauê Faleiros
7eedbded2d Fix production plans dashboard typing 2026-08-25 11:53:31 -03:00
Cauê Faleiros
85afba4e5b Add persistent production plans 2026-08-25 11:47:07 -03:00
Cauê Faleiros
21d9960145 Add supply purchase order workflow 2026-08-25 11:10:36 -03:00
Cauê Faleiros
91f3e5e00c Reserve material lots from cut plans 2026-08-25 10:55:54 -03:00
Cauê Faleiros
a226e6d9f4 Add production execution workflow 2026-08-25 10:44:39 -03:00
Cauê Faleiros
713cc4a9fc Document supply and Olist operating model 2026-08-25 09:14:24 -03:00
Cauê Faleiros
11443a9202 Unify material supplier and balance controls 2026-08-24 16:43:03 -03:00
Cauê Faleiros
7e18772a8a Add audited Graphs stock balances
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m33s
2026-08-24 14:34:25 -03:00
Cauê Faleiros
71c80fd5d4 Fix cut registration CSV export
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m42s
2026-08-24 12:52:09 -03:00
Cauê Faleiros
8dfbc8ea9e Export pending data queues to CSV
Some checks failed
Build and Deploy / build-and-deploy (push) Failing after 2m26s
2026-08-24 10:39:40 -03:00
Cauê Faleiros
63157b0934 Center pending planning detail
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m49s
2026-08-17 15:18:22 -03:00
Cauê Faleiros
466e85fa48 Equalize pending data detail columns
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 52s
2026-08-17 14:15:47 -03:00
Cauê Faleiros
f5d17f95d4 Balance pending data column spacing
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 41s
2026-08-17 14:11:19 -03:00
Cauê Faleiros
cc5bdea1b5 Align pending data details with actions
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m5s
2026-08-17 14:00:05 -03:00
Cauê Faleiros
142b4d5889 Separate pending data balance columns
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 42s
2026-08-17 13:40:32 -03:00
Cauê Faleiros
a6d60ee42c Space pending data table columns
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 56s
2026-08-17 13:03:57 -03:00
Cauê Faleiros
a9cae33ca0 Refine pending data tables
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m27s
2026-08-17 12:38:27 -03:00
Cauê Faleiros
36981c9ac5 Manage manual product compositions 2026-08-14 14:28:47 -03:00
Cauê Faleiros
2fb9092e05 Simplify imported production orders view
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 47s
2026-08-13 13:58:06 -03:00
Cauê Faleiros
5fbdd354c7 Paginate Olist monitor history
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m17s
2026-08-13 13:44:59 -03:00
Cauê Faleiros
bee9e6dc2e Revert "Add two-week changes summary PDF"
This reverts commit 0bfd5a7e6f.
2026-08-12 15:12:03 -03:00
Cauê Faleiros
80b5a7d907 Revert "Move changes summary PDF to repository root"
This reverts commit 7d0ac329cb.
2026-08-12 15:12:03 -03:00
Cauê Faleiros
7d0ac329cb Move changes summary PDF to repository root 2026-08-12 15:10:51 -03:00
Cauê Faleiros
0bfd5a7e6f Add two-week changes summary PDF 2026-08-12 15:07:02 -03:00
Cauê Faleiros
743dc55687 Return open orders to cut plan
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m4s
2026-08-12 14:59:24 -03:00
Cauê Faleiros
52d3137ef5 Select production orders in cut preview
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 50s
2026-08-12 14:52:06 -03:00
Cauê Faleiros
cfcd8e06f0 Fix Olist import state after yield update
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 54s
2026-08-12 14:43:17 -03:00
Cauê Faleiros
a574b032e0 Align cutting families with Olist production data
Some checks failed
Build and Deploy / build-and-deploy (push) Failing after 1m17s
2026-08-12 14:09:29 -03:00
Cauê Faleiros
e7edb5c483 Export production orders with missing data
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m2s
2026-08-12 11:41:37 -03:00
Cauê Faleiros
cfdcc4f2f4 Remove yield input number spinners 2026-08-12 11:25:28 -03:00
Cauê Faleiros
7e772bbf79 Center cutting configuration dialog 2026-08-12 11:16:06 -03:00
40 changed files with 5519 additions and 368 deletions

View File

@@ -1,7 +1,7 @@
# Context # Context
## 1. Project Overview ## 1. Project Overview
This project (often referred to as "Nexstar Graphs" or simply "Graphs") is a real-time sales and inventory dashboard. Its primary purpose is to ingest live webhook payloads from an external ERP (Tiny ERP) via n8n, securely store that data, and provide a visually rich, responsive dashboard for business analytics. It tracks total sales, product performance, customer behavior, and live inventory levels, while also providing tools for WhatsApp marketing campaigns. This project (often referred to as "Nexstar Graphs" or simply "Graphs") is a real-time sales, inventory, and operations dashboard. It ingests webhook payloads from Tiny/Olist through n8n, securely stores that data, and provides business analytics, WhatsApp campaign tools, and a local Suprimentos/PCP operating layer. The operational flow is: **Necessidade de Compra → Plano de Corte → Ordens de Produção**.
## 2. Tech Stack & Tooling ## 2. Tech Stack & Tooling
@@ -37,8 +37,8 @@ This project (often referred to as "Nexstar Graphs" or simply "Graphs") is a rea
* **Backend Analytics Foundation:** Raw `/api/data` still exists for legacy pages, but dashboard/product/client aggregate endpoints now exist under `/api/analytics/*`. Dashboard already uses the backend dashboard aggregation endpoint so it does not wait on the full raw order download. * **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. * **Tiny as Current Source, Graphs as Operating Layer:** Tiny/Olist remains the source for ERP sales, product/order metadata and imported stock. Graphs owns operational enrichment: cut configuration, SKU families, manual material details, consumption references, local production orders, and audited physical-stock overrides. `NECESSIDADE DE CORTE.xlsx` is reference material only and must not become a runtime dependency.
* **Tiny/Olist Composition Import:** Product structures from Tiny/Olist V3 are imported into `product_compositions` and `product_composition_components`. Sellable finished-product structures are also promoted into local catalog products/materials and `consumption_references` with source `tiny_structure`, so purchase planning can use real component consumption instead of guessed kg-per-piece rules. * **Tiny/Olist Composition Import:** Product structures from Tiny/Olist V3 are imported into `product_compositions` and `product_composition_components`. Sellable finished-product structures are promoted into local catalog products/materials and `consumption_references` with source `tiny_structure`, so purchase planning uses real component consumption rather than guessed kg-per-piece rules. Operators can also add, edit, and remove local composition lines when the ERP structure is missing or requires a controlled correction.
## 4. Directory Structure ## 4. Directory Structure
```text ```text
@@ -69,7 +69,8 @@ This project (often referred to as "Nexstar Graphs" or simply "Graphs") is a rea
* **Order Entity (`orders` table):** Tracks `cliente_nome`, `data_pedido`, normalized `data_pedido_date`, `valor_pedido`, `produto_id`, `quantidade`, `valor_unitario`, `pedido_id`, and `cliente_fone`. Tiny ERP metadata is also persisted when present: `cliente_nome_fantasia`, `id_vendedor`, `nome_vendedor`, `marketplace`, `canal_venda`, and `numero_ecommerce`. * **Order Entity (`orders` table):** Tracks `cliente_nome`, `data_pedido`, normalized `data_pedido_date`, `valor_pedido`, `produto_id`, `quantidade`, `valor_unitario`, `pedido_id`, and `cliente_fone`. Tiny ERP metadata is also persisted when present: `cliente_nome_fantasia`, `id_vendedor`, `nome_vendedor`, `marketplace`, `canal_venda`, and `numero_ecommerce`.
* **Order Metadata Preservation:** Order upserts preserve existing non-empty phone and Tiny metadata values when incoming backfill rows send `NULL` or empty strings. Core mutable order/item fields still overwrite normally. * **Order Metadata Preservation:** Order upserts preserve existing non-empty phone and Tiny metadata values when incoming backfill rows send `NULL` or empty strings. Core mutable order/item fields still overwrite normally.
* **Client Identity Rule:** Analytics groups clients by a stable normalized identity so adding a phone number later does not split an existing buyer into a duplicate client. When a phone is added after historical purchases without phone, the old and new rows should resolve to the same client profile and history. * **Client Identity Rule:** Analytics groups clients by a stable normalized identity so adding a phone number later does not split an existing buyer into a duplicate client. When a phone is added after historical purchases without phone, the old and new rows should resolve to the same client profile and history.
* **Stock Entity (`stock` table):** Tracks `produto_id`, `nome`, `saldo` (absolute current inventory), and `delta_estoque`. The database treats the ERP's `saldo` as the Absolute Truth (overwriting existing values rather than performing math) to prevent desynchronization. * **Imported Stock Entity (`stock` table):** Tracks `produto_id`, `nome`, `saldo` (the absolute current balance reported by Tiny), and `delta_estoque`. Tiny stock remains the imported/audit value and is never overwritten by Graphs.
* **Graphs Physical-Balance Override:** `stock_graphs_balances` stores an operator-verified, absolute physical balance with reason, effective date, and editor. `stock_graphs_balance_history` records each set/removal along with the Tiny value at that point. Planning uses the Graphs balance when present; otherwise it uses Tiny. Removing the override returns planning to Tiny without modifying Tiny data.
* **Campaign Queue Entity (`stock_campaign_queue` table):** Tracks queued product restock deltas by `base_product_name`, product ID/name, delta, status (`pending`, `processing`, `sent`, `failed`, `skipped`), attempts, errors, and timestamps. * **Campaign Queue Entity (`stock_campaign_queue` table):** Tracks queued product restock deltas by `base_product_name`, product ID/name, delta, status (`pending`, `processing`, `sent`, `failed`, `skipped`), attempts, errors, and timestamps.
* **WhatsApp Campaign Payload:** The backend sends one n8n webhook payload per scheduled campaign run. The payload includes `baseProduct`, `productsText`, `total_delta`, `sizes`, `products`, and `customers`. * **WhatsApp Campaign Payload:** The backend sends one n8n webhook payload per scheduled campaign run. The payload includes `baseProduct`, `productsText`, `total_delta`, `sizes`, `products`, and `customers`.
* **Campaign Product Display Names:** Product display text is customer-facing and may differ from internal grouping. Current aliases: `BASE LISA CAMISETA ...` -> `Camiseta Premium ...`, `BASE LISA OVER SIZE ...` -> `Camiseta Premium Over Size ...`, and `BASE LISA MOLETOM CANGURU ...` -> `Moletom Canguru Premium ...`. * **Campaign Product Display Names:** Product display text is customer-facing and may differ from internal grouping. Current aliases: `BASE LISA CAMISETA ...` -> `Camiseta Premium ...`, `BASE LISA OVER SIZE ...` -> `Camiseta Premium Over Size ...`, and `BASE LISA MOLETOM CANGURU ...` -> `Moletom Canguru Premium ...`.
@@ -112,7 +113,7 @@ yield: 4.8 units/kg
* **Finished SKU catalog:** SKU/id, name, color, size, product family, cut family, active/inactive. * **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. * **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. * **Consumption references:** finished SKU, material product, yield per kg, optional yield by size, optional color/family overrides, and secondary materials when needed.
* **Imported product compositions:** Tiny/Olist structures are stored as immutable-ish sync facts: finished SKU/Tiny ID/unit, component SKU/Tiny ID/name, quantity per finished unit, and component unit (`KG`, `UN`, etc.). These records support Product Details composition display and drive `tiny_structure` consumption references. * **Imported and local product compositions:** Tiny/Olist structures retain finished SKU/Tiny ID/unit, component SKU/Tiny ID/name, quantity per finished unit, and component unit (`KG`, `UN`, etc.). They support Product Details composition display and `tiny_structure` consumption references; local maintenance can add or correct composition data where operationally required.
* **Cut family mapping:** rules that say which colors/sizes/products can be planned together for cutting. * **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. * **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. * **Business rules:** target coverage days, minimum stock, safety stock, purchase lead time, production lead time, and discontinued/ignored SKU rules.
@@ -120,14 +121,20 @@ yield: 4.8 units/kg
**Current UI/data-flow behavior:** **Current UI/data-flow behavior:**
* `Cadastros` has an `Importar composições` JSON upload action for exports like `composicoes_produtos_YYYY-MM-DD.json`. The same import is available to scripts through `POST /api/production-orders/tiny-compositions/import` using `x-api-key`. * `Cadastros` has an `Importar composições` JSON upload action for exports like `composicoes_produtos_YYYY-MM-DD.json`. The same import is available to scripts through `POST /api/production-orders/tiny-compositions/import` using `x-api-key`.
* Composition imports are idempotent. Re-importing the same Tiny/Olist export updates composition rows/components and refreshes derived `tiny_structure` consumption references instead of duplicating them. * Composition imports are idempotent. Re-importing the same Tiny/Olist export updates composition rows/components and refreshes derived `tiny_structure` consumption references instead of duplicating them.
* `Product Details > Composição` shows the synced component list for the current product when a composition exists. * `Product Details > Composição` shows the effective component list and supports manual add, edit, and delete actions. This is intentionally available even when no synced composition exists.
* `Suprimentos > Necessidade de Compra` derives material pressure from demand and stock, but rows without a consumption reference are marked as missing reference instead of pretending to calculate kg. * `Suprimentos > Necessidade de Compra` derives material pressure from demand and stock, but rows without a consumption reference are marked as missing reference instead of pretending to calculate kg.
* Imported `tiny_structure` consumption references now allow `Necessidade de Compra` to calculate mixed-unit material demand from real BOM lines, e.g. malha/ribana in `KG` and etiqueta/ilhós/atacador in `UN`. * Imported `tiny_structure` consumption references now allow `Necessidade de Compra` to calculate mixed-unit material demand from real BOM lines, e.g. malha/ribana in `KG` and etiqueta/ilhós/atacador in `UN`.
* Missing-reference rows link into `Cadastros > Referência de Consumo` with SKU context. If the SKU does not exist in the local catalog yet, `Cadastros` opens the product form first. * Missing-reference rows link into `Cadastros > Referência de Consumo` with SKU context. If the SKU does not exist in the local catalog yet, `Cadastros` opens the product form first.
* `Cadastros > Referência de Consumo` labels reference sources as `Manual`, `Tiny OP`, or `Tiny Estrutura`. * `Cadastros > Referência de Consumo` labels reference sources as `Manual`, `Tiny OP`, or `Tiny Estrutura`.
* `Plano de Corte` SKU edit actions deep-link into the cutting configuration drawer for that SKU. * `Plano de Corte` SKU edit actions open the focused cut-configuration dialog for that SKU. Family yields and SKU corrections are managed locally; historical Olist OP data can provide a suggested yield but never silently overwrites a configured value.
* Product tables and group detail tables are tuned for high-volume values, including large `Total Vendido` quantities above 100k. * Product tables and group detail tables are tuned for high-volume values, including large `Total Vendido` quantities above 100k.
**Planning safety rules:**
* A finished SKU without a reliable imported/manual composition must **not** block other SKUs. It produces no material demand or purchase need, but can still intentionally enter a cut plan and a local OP.
* A negative Tiny or Graphs balance is visible for audit, but counts as **zero available stock** in planning. It neither blocks the supply flow nor inflates purchase suggestions. Example: demand `500` and Tiny stock `-180` means purchase `500`, not `680`.
* `Dados Pendentes` is an informational/audit centre, not a blocker. It separates negative balances, SKUs without compositions, and incomplete cut registration data. Active queues can be exported to CSV for correction work.
* Material details are editable in a shared modal: display name, preferred supplier (from the Graphs supplier list), operational notes, and the optional audited Graphs physical balance. This keeps material editing consistent from Supply and Data Pending screens.
**Current composition import status:** **Current composition import status:**
* Local import tested with `/home/farelos/Downloads/composicoes_produtos_2026-07-31.json`. * Local import tested with `/home/farelos/Downloads/composicoes_produtos_2026-07-31.json`.
* Imported successfully: `437` compositions, `1,552` component rows, and `1,316` `tiny_structure` consumption references. * Imported successfully: `437` compositions, `1,552` component rows, and `1,316` `tiny_structure` consumption references.
@@ -141,13 +148,54 @@ yield: 4.8 units/kg
* Upgrade `Planejamento de Corte` to show material blockers by family/color/size, using imported composition references and current stock. * Upgrade `Planejamento de Corte` to show material blockers by family/color/size, using imported composition references and current stock.
* Add production-plan status flow only after material needs and composition health are reliable. * Add production-plan status flow only after material needs and composition health are reliable.
## 7. CI/CD & Deployment ## 7. Current Operations: Supply, Cutting, Production, and Olist
### 7.1 Suprimentos and PCP
* The six supply views are: the Suprimentos hub, Necessidade de Compra, Plano de Corte, Ordens de Produção, Estoque e Recebimentos, and Planejamento de Malha/Prioridades de Estoque.
* The hub prioritises real material purchases, then active local production orders, then cutting work. It does not elevate missing compositions or negative balances as operational blockers.
* Purchase calculations use actual BOM consumption and the effective planning balance. Negative balances are displayed for audit, but `getAvailablePlanningStock()` clamps availability to zero.
* `Cadastros` paginates Products, Categories, and Consumption References with 10 rows by default. Products, categories, consumption references, and compositions have local maintenance actions. Categories use the same compact action style as other tables.
* The cut plan has a compact demand table, filters, pagination, direct product actions, and a centred configuration dialog. The configuration manages cut-family yields and product corrections without a right-side drawer. Numeric yield inputs hide native browser spinners.
* The OP preview is paginated and selectable. Operators can select individual SKUs, all visible/eligible SKUs, or use the ready-data subset before confirming generation. Existing open local OPs for the same cut plan and SKU are ignored to prevent duplicate local OPs.
* Graphs-created OPs are local records, with incremental display numbers such as `OP-01999`; the number is not random and is not an Olist OP number. An open OP can be returned to the cut plan, restoring its quantity; progressed/finished work must not be returned casually.
* The production-order screen uses a compact list with inline expansion rather than a side detail page. Each OP has one status control (`Em aberto`, `Em andamento`, `Finalizada`, `Cancelada`), and expanded details show product markers, composition, and material consumption context. Material consumption is an OP-level action, relevant once production has started rather than a permanent page-level form.
### 7.2 Olist V3 connection and composition synchronisation
* Graphs has an **Administração** section available to every signed-in user. Its **Olist** monitor at `/#/admin/olist` owns the connection action, manual sync, reconnection, stop action, live status, run history, run logs, and affected-product view; Cadastros does not own the Olist connection flow. The same section includes user management at `/#/admin/users`.
* OAuth uses Olist/Tiny V3 with an authorization code and refresh token. Tokens are encrypted before storage in `olist_connections`; access tokens are refreshed automatically before expiry. The monitor shows the token expiry as an operational status, not an indication that the connection has failed.
* Required production variables are `OLIST_CLIENT_ID`, `OLIST_CLIENT_SECRET`, `OLIST_REDIRECT_URI`, `OLIST_FRONTEND_URL`, `OLIST_TOKEN_ENCRYPTION_KEY`, and `OLIST_SYNC_ENABLED=true`. The encryption key must be stable (a 32-byte base64 key or 64-character hex key): changing it makes already stored tokens unreadable and requires reconnection.
* The OAuth callback route is `GET /api/olist/oauth/callback`; `OLIST_REDIRECT_URI` must be exactly that publicly reachable backend URL. After authorization Graphs redirects to `/#/admin/olist` on `OLIST_FRONTEND_URL`.
* Native sync first lists Olist products, then checks each candidate with `GET /public-api/v3/produtos/{idProduto}/fabricado`. Products with a manufactured BOM are upserted through the existing composition importer; products without a BOM are recorded as checked but do not produce fictional material demand.
* The first run is a full catalogue/BOM scan. Later runs request product changes using `dataAlteracao`; cached products whose structure has not yet been checked remain pending and are resumed before a fully incremental run. This makes a stopped or server-interrupted scan resumable instead of starting the BOM checks over.
* Olist requests are intentionally throttled to a minimum `2,500 ms` apart (`OLIST_SYNC_REQUEST_DELAY_MS`, floor 2500). This is at most 24 requests/minute, deliberately below Tiny's 60 requests/minute limit so other ERP automations retain capacity.
* With `OLIST_SYNC_ENABLED=true`, backend startup schedules the automatic composition sync daily at **11:00 America/Sao_Paulo** (`OLIST_SYNC_HOUR = 11`). It is an in-process scheduler: the backend must be running at that time. Manual sync remains the fallback. The UI reports the next scheduled run and the status/error from the latest run.
* Sync execution is single-run protected by a PostgreSQL advisory lock. Runs, progress, errors, affected products, and events are persisted in `olist_sync_runs` and `olist_sync_events`. The user may request a stop; the worker finishes the current safe step, marks the run cancelled, and later resumes pending structures. Server restart marks a running run failed with an explicit interruption message; pending cache entries still permit a later resume.
* Olist monitor history is paginated with five rows by default. Run-detail event logs and affected-product lists are paginated as well, so status polling does not require reading an unbounded history.
### 7.3 Confirmed Olist API scope and the OP CSV bridge
* **Confirmed by Olist AI/documentation:** V3 and V2 do not provide a supported endpoint to list or read existing Production Orders (OPs), OP status/history, production steps, notes, lots, suppliers, roll quantities, fabric kg, or yield. There is no OP webhook and no incremental OP endpoint. Graphs must not scrape the ERP interface.
* The only production-order endpoint reported by Olist is generation from a sales order (`POST /public-api/v3/pedidos/{idPedido}/gerar-ordem-producao`; V2 equivalent `gerar.ordem.producao.pedido.php`). It does **not** provide subsequent OP reading and is not used as a history feed.
* Product/BOM sync therefore needs only **Products / Read** permission. Additional production permission in the application screen cannot unlock an endpoint that the API does not expose.
* Historical finalized OPs enter Graphs through the manual **Importar OPs finalizadas** CSV bridge. The CSV must include the required columns: `id_op`, `numero_op`, `sku_produto`, `descricao_produto`, `quantidade_produzida`, `data_emissao`, `data_conclusao`, `situacao`, and `observacoes`. Useful optional columns are `pedido_relacionado`, `fornecedor`, `lote`, `quantidade_rolos`, `quilos_malha`, `quilos_ribana`, and `rendimento`.
* The importer accepts only `Finalizada` records with a valid ID, number, SKU, description, and positive quantity. It is idempotent: it matches the existing record by Tiny internal ID or OP number, updating it rather than duplicating it. The import history explicitly reports new, updated, ignored, and failed rows.
* Optional operational values may be provided directly as columns or extracted from `observacoes`. Supported conventions include `FORNECEDOR:`, `RENDIMENTO:`, `BOBINAS:`/`ROLOS:`/`QUANTIDADE DE ROLOS:`, `KG:`, `QUILOS DE MALHA:`, `RIBANA:`, and lot variants such as `Lote 01`, `Lote: 01`, and `- Lote 01`. Bobbin multipliers such as `07(X2)` are recognised. Supplier in this historical OP context is treated as the fabric supplier.
* Importing completed OP history stores the raw operational facts (supplier, lot, rolls, fabric/rib kg, and pieces/kg yield) in `production_orders`, records each file in `production_order_import_runs`, and recalculates yield recommendations. Recommendations are available by family and by family + supplier. The cut configuration shows a suggested **units per roll** only when enough imported factual data exists; using a suggestion is an explicit operator action.
* The current historical-yield aggregation covers `BLCS`, `BLOS`, `BLMC`, `BLPM`, and `BLRM`. Family reading is: `BLCS` Camiseta Regular, `BLOS` Oversize, `BLMC` Moletom Canguru, `BLPM` Camiseta Pima, and `BLRM` Regata Base. `BLRM` is a regata family and must not be grouped with `BLMC` or its moletom materials. Supplier-specific variation is meaningful and should be treated as a recommendation to validate with the cutting team, not an automatic truth.
* Graphs can export a **missing finalized OP data** CSV from its imported history. That file is the correction queue to send to the Olist AI/ERP extraction process. Olist AI exports must be requested in manageable OP-number batches when large note fields cause failures. Re-importing corrected batches is safe because they update the matching OPs.
* The Olist page keeps family recommendation cards visible in both the supplier-reference and imported-file tabs. Supplier references and imported file runs are tables with five rows per page, keeping the previous dense tag wall out of the main workflow.
### 7.4 Supply audit and supplier sources
* Tiny negative balances were investigated as an operational-data problem, typically stock exits without matching entries or historic migration/balance errors. The balance must remain visible for auditing but must not turn into an additional procurement deficit.
* A Graphs physical balance is the way to record a verified count while preserving Tiny's reported balance. The correction must include a reason and is fully auditable through the balance history; it is not a hidden manipulation of imported stock.
* The selectable preferred-supplier list is intentionally built from local material details, lots, receipts, fabric plans, and the imported OP supplier recommendations. It is a controlled Graphs list, not an assumption that Olist exposes a dedicated supplier master through this integration.
## 8. 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`.
## 8. Environment Setup & Scripts ## 9. Environment Setup & Scripts
**Running Locally:** **Running Locally:**
1. Start the database: 1. Start the database:
@@ -199,7 +247,7 @@ The local Docker services use `restart: unless-stopped`, so containers should co
``` ```
* Expected response includes `imported`, `failed`, `componentCount`, `referenceCount`, `skippedReferenceCount`, and `issues`. * Expected response includes `imported`, `failed`, `componentCount`, `referenceCount`, `skippedReferenceCount`, and `issues`.
## 9. Coding Standards & AI Directives ## 10. 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`.
@@ -208,7 +256,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.
## 10. Recent Work ## 11. Historical 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.
@@ -236,7 +284,7 @@ Files most relevant to RFV:
Recent commits related to supply/cutting/data-flow and chart readability: Recent commits related to supply/cutting/data-flow and chart readability:
* `37d4a77 Import Tiny product compositions` - adds the Tiny/Olist composition JSON import flow, derived `tiny_structure` consumption references, app upload button, API-key import endpoint, and import tests. * `37d4a77 Import Tiny product compositions` - adds the Tiny/Olist composition JSON import flow, derived `tiny_structure` consumption references, app upload button, API-key import endpoint, and import tests.
* `800eb97 Derive consumption references from Tiny OP sync` - Tiny OP composition sync now creates catalog products/materials and reusable consumption references from OP component lines. * `800eb97 Derive consumption references from Tiny OP sync` - Tiny OP composition sync now creates catalog products/materials and reusable consumption references from OP component lines.
* `c07938c Add Tiny production order detail sync` - stores OP detail rows, components, steps, and observations from Tiny/Olist sync payloads. * `c07938c Add Tiny production order detail sync` - an earlier internal/import-oriented OP detail foundation. Current Olist API knowledge supersedes the assumption of native OP history reads: existing Olist OP history is now imported through the explicit CSV bridge documented in section 7.3.
* `060b4da Connect supplies to project demand` - Suprimentos purchase needs now derive from project/order demand and stock, while missing consumption references are surfaced explicitly. * `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. * `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. * `63efb47 Route SKU actions to focused editors` - cut-plan edit opens SKU cutting configuration; missing-reference supply rows open the consumption reference flow.
@@ -252,6 +300,6 @@ Files most relevant to supply/cutting/data-flow:
* `src/analytics/cutting.ts` - cut-family parsing, cut needs, stock coverage, and product override logic. * `src/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/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. * `src/chartUtils.ts` - shared date bucketing, moving average, unknown-label cleanup, and chart date helpers.
* `backend/services/productionOrderService.js` - Tiny OP detail sync, Tiny/Olist structure import, composition storage, sellable structure filtering, and derived consumption-reference creation. * `backend/services/productionOrderService.js` - local production-order management, composition storage, sellable structure filtering, and derived consumption-reference creation. Historical finalized Olist OP CSV import lives in `backend/services/productionOrderCsvImportService.js`.
* `backend/routes/productionOrderRoutes.js` and `backend/routes/catalogRoutes.js` - API-key script import and authenticated app import endpoints for composition JSON. * `backend/routes/productionOrderRoutes.js` and `backend/routes/catalogRoutes.js` - API-key script import and authenticated app import endpoints for composition JSON.
* `backend/test/productCompositionService.test.js` - coverage for Tiny/Olist structure sync and bulk composition import behavior. * `backend/test/productCompositionService.test.js` - coverage for Tiny/Olist structure sync and bulk composition import behavior.

View File

@@ -97,6 +97,35 @@ const initDB = async () => {
ALTER COLUMN updated_at SET DEFAULT CURRENT_TIMESTAMP; ALTER COLUMN updated_at SET DEFAULT CURRENT_TIMESTAMP;
`).catch(() => {}); `).catch(() => {});
// Tiny/Olist remains the imported source balance. Graphs physical balances
// are stored separately so planning can use a verified count without
// overwriting the audit value received from Tiny.
await pool.query(`
CREATE TABLE IF NOT EXISTS stock_graphs_balances (
produto_id VARCHAR(100) PRIMARY KEY,
balance NUMERIC(14, 4) NOT NULL,
reason TEXT NOT NULL,
effective_at DATE,
updated_by VARCHAR(255),
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
`);
await pool.query(`
CREATE TABLE IF NOT EXISTS stock_graphs_balance_history (
id SERIAL PRIMARY KEY,
produto_id VARCHAR(100) NOT NULL,
tiny_balance NUMERIC(14, 4),
graphs_balance NUMERIC(14, 4),
reason TEXT,
effective_at DATE,
action VARCHAR(20) NOT NULL DEFAULT 'set',
changed_by VARCHAR(255),
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
`);
await pool.query(` await pool.query(`
CREATE TABLE IF NOT EXISTS stock_campaign_queue ( CREATE TABLE IF NOT EXISTS stock_campaign_queue (
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,
@@ -141,6 +170,46 @@ const initDB = async () => {
); );
`); `);
// A production plan is the PCP container above the individual OPs. The
// calculated cut screen may change tomorrow; a confirmed plan must not.
await pool.query(`
CREATE TABLE IF NOT EXISTS production_plans (
id SERIAL PRIMARY KEY,
number VARCHAR(40) UNIQUE,
name TEXT NOT NULL,
status VARCHAR(30) NOT NULL DEFAULT 'open',
contractor TEXT,
planned_start_date DATE,
expected_date DATE,
notes TEXT,
source VARCHAR(40) NOT NULL DEFAULT 'cut_plan',
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
completed_at TIMESTAMPTZ
);
`);
// The release decision remains auditable when the plan changes status.
await pool.query(`
CREATE TABLE IF NOT EXISTS production_plan_reviews (
id SERIAL PRIMARY KEY,
production_plan_id INTEGER NOT NULL UNIQUE REFERENCES production_plans(id) ON DELETE CASCADE,
decision VARCHAR(30) NOT NULL DEFAULT 'pending',
checklist JSONB NOT NULL DEFAULT '{}'::jsonb,
notes TEXT,
reviewed_by TEXT,
reviewed_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
`);
await pool.query(`
ALTER TABLE production_orders
ADD COLUMN IF NOT EXISTS production_plan_id INTEGER REFERENCES production_plans(id) ON DELETE SET NULL,
ADD COLUMN IF NOT EXISTS contractor TEXT;
`).catch(() => {});
await pool.query(` await pool.query(`
CREATE TABLE IF NOT EXISTS production_order_markers ( CREATE TABLE IF NOT EXISTS production_order_markers (
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,
@@ -182,6 +251,43 @@ const initDB = async () => {
); );
`); `);
await pool.query(`
CREATE TABLE IF NOT EXISTS production_workstations (
id SERIAL PRIMARY KEY,
code VARCHAR(60) NOT NULL UNIQUE,
name VARCHAR(160) NOT NULL,
color VARCHAR(40) NOT NULL DEFAULT '#38bdf8',
sort_order INTEGER NOT NULL DEFAULT 0,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
`);
await pool.query(`
INSERT INTO production_workstations (code, name, color, sort_order)
VALUES
('cutting', 'Corte', '#fbbf24', 10),
('sewing', 'Costura', '#38bdf8', 20),
('review', 'Revisão', '#a78bfa', 30),
('sac', 'SAC', '#fb7185', 40)
ON CONFLICT (code) DO NOTHING;
`);
await pool.query(`
CREATE TABLE IF NOT EXISTS production_order_defects (
id SERIAL PRIMARY KEY,
production_order_id INTEGER NOT NULL REFERENCES production_orders(id) ON DELETE CASCADE,
workstation_id INTEGER REFERENCES production_workstations(id) ON DELETE SET NULL,
defect_type VARCHAR(160) NOT NULL,
origin VARCHAR(160),
quantity NUMERIC(14, 4) NOT NULL CHECK (quantity > 0),
lot_code VARCHAR(120),
notes TEXT,
occurred_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
);
`);
await pool.query(` await pool.query(`
CREATE TABLE IF NOT EXISTS product_compositions ( CREATE TABLE IF NOT EXISTS product_compositions (
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,
@@ -414,6 +520,31 @@ const initDB = async () => {
); );
`); `);
await pool.query(`
CREATE TABLE IF NOT EXISTS cut_plan_lot_reservations (
id SERIAL PRIMARY KEY,
cut_plan_reference TEXT NOT NULL,
production_plan_id INTEGER REFERENCES production_plans(id) ON DELETE SET NULL,
supply_lot_id INTEGER NOT NULL REFERENCES supply_stock_lots(id) ON DELETE RESTRICT,
material_key VARCHAR(255) NOT NULL,
quantity NUMERIC(14, 4) NOT NULL CHECK (quantity > 0),
consumed_quantity NUMERIC(14, 4) NOT NULL DEFAULT 0 CHECK (consumed_quantity >= 0),
status VARCHAR(30) NOT NULL DEFAULT 'active',
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
released_at TIMESTAMPTZ
);
`);
await pool.query(`
ALTER TABLE cut_plan_lot_reservations
ADD COLUMN IF NOT EXISTS production_plan_id INTEGER REFERENCES production_plans(id) ON DELETE SET NULL;
`).catch(() => {});
await pool.query(`CREATE INDEX IF NOT EXISTS idx_cut_plan_lot_reservations_lot_status ON cut_plan_lot_reservations (supply_lot_id, status);`);
await pool.query(`CREATE INDEX IF NOT EXISTS idx_cut_plan_lot_reservations_reference ON cut_plan_lot_reservations (cut_plan_reference, status);`);
await pool.query(`CREATE INDEX IF NOT EXISTS idx_cut_plan_lot_reservations_plan ON cut_plan_lot_reservations (production_plan_id, status);`);
await pool.query(` await pool.query(`
CREATE TABLE IF NOT EXISTS supply_movements ( CREATE TABLE IF NOT EXISTS supply_movements (
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,
@@ -443,6 +574,63 @@ const initDB = async () => {
); );
`); `);
// A transformation is a planning/execution record, not a stock movement.
// Physical stock remains controlled only by approved receipts and inventory
// adjustments, so a planned dyeing run can never inflate availability.
await pool.query(`
CREATE TABLE IF NOT EXISTS supply_material_transformations (
id SERIAL PRIMARY KEY,
batch_code VARCHAR(120),
stage VARCHAR(40) NOT NULL,
input_material TEXT NOT NULL,
input_quantity_kg NUMERIC(14, 4) NOT NULL CHECK (input_quantity_kg > 0),
output_material TEXT NOT NULL,
output_color VARCHAR(120),
expected_loss_percent NUMERIC(7, 4) NOT NULL DEFAULT 0 CHECK (expected_loss_percent >= 0 AND expected_loss_percent < 100),
expected_output_kg NUMERIC(14, 4) NOT NULL CHECK (expected_output_kg >= 0),
supplier TEXT,
priority VARCHAR(40) NOT NULL DEFAULT 'Normal',
status VARCHAR(30) NOT NULL DEFAULT 'planned',
notes TEXT,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
`);
await pool.query(`CREATE INDEX IF NOT EXISTS idx_supply_material_transformations_status ON supply_material_transformations (status, created_at DESC);`);
await pool.query(`CREATE INDEX IF NOT EXISTS idx_supply_material_transformations_batch ON supply_material_transformations (batch_code, created_at DESC);`);
await pool.query(`
CREATE TABLE IF NOT EXISTS supply_purchase_orders (
id SERIAL PRIMARY KEY,
number VARCHAR(40) UNIQUE,
supplier TEXT NOT NULL,
status VARCHAR(30) NOT NULL DEFAULT 'draft',
expected_date DATE,
notes TEXT,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
`);
await pool.query(`
CREATE TABLE IF NOT EXISTS supply_purchase_order_items (
id SERIAL PRIMARY KEY,
purchase_order_id INTEGER NOT NULL REFERENCES supply_purchase_orders(id) ON DELETE CASCADE,
material TEXT NOT NULL,
quantity NUMERIC(14, 4) NOT NULL CHECK (quantity > 0),
unit VARCHAR(30) NOT NULL DEFAULT 'kg',
unit_cost NUMERIC(14, 4),
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
`);
await pool.query(`CREATE INDEX IF NOT EXISTS idx_supply_purchase_orders_status ON supply_purchase_orders (status, created_at DESC);`);
await pool.query(`
ALTER TABLE supply_receipts
ADD COLUMN IF NOT EXISTS purchase_order_id INTEGER REFERENCES supply_purchase_orders(id) ON DELETE SET NULL;
`).catch(() => {});
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 notes TEXT,
@@ -459,6 +647,16 @@ const initDB = async () => {
ADD COLUMN IF NOT EXISTS completed_date DATE; ADD COLUMN IF NOT EXISTS completed_date DATE;
`).catch(() => {}); `).catch(() => {});
await pool.query(`
ALTER TABLE production_orders
ADD COLUMN IF NOT EXISTS actual_cut_quantity NUMERIC(14, 4),
ADD COLUMN IF NOT EXISTS rejected_quantity NUMERIC(14, 4) NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS finished_quantity NUMERIC(14, 4),
ADD COLUMN IF NOT EXISTS quality_notes TEXT,
ADD COLUMN IF NOT EXISTS cut_layers INTEGER,
ADD COLUMN IF NOT EXISTS marker_reference VARCHAR(160);
`).catch(() => {});
await pool.query(` await pool.query(`
CREATE TABLE IF NOT EXISTS production_order_import_runs ( CREATE TABLE IF NOT EXISTS production_order_import_runs (
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,
@@ -489,6 +687,22 @@ const initDB = async () => {
); );
`); `);
await pool.query(`
CREATE TABLE IF NOT EXISTS production_yield_supplier_recommendations (
family_key VARCHAR(20) NOT NULL,
supplier VARCHAR(255) NOT NULL,
sample_count INTEGER NOT NULL DEFAULT 0,
yield_sample_count INTEGER NOT NULL DEFAULT 0,
roll_sample_count INTEGER NOT NULL DEFAULT 0,
avg_yield_pieces_per_kg NUMERIC(14, 4),
avg_kg_per_roll NUMERIC(14, 4),
recommended_units_per_roll NUMERIC(14, 4),
last_completed_date DATE,
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (family_key, supplier)
);
`);
await pool.query(` await pool.query(`
ALTER TABLE production_orders ALTER TABLE production_orders
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',
@@ -625,6 +839,14 @@ const initDB = async () => {
ALTER COLUMN updated_at SET DEFAULT CURRENT_TIMESTAMP; ALTER COLUMN updated_at SET DEFAULT CURRENT_TIMESTAMP;
`).catch(() => {}); `).catch(() => {});
// A production plan is operated by a Graphs user. The ID link will let
// role-based assignments be added without changing historical plans.
await pool.query(`
ALTER TABLE production_plans
ADD COLUMN IF NOT EXISTS responsible_user_id INTEGER REFERENCES app_users(id) ON DELETE SET NULL,
ADD COLUMN IF NOT EXISTS responsible_name TEXT;
`).catch(() => {});
await pool.query(` await pool.query(`
ALTER TABLE stock_campaign_queue ALTER TABLE stock_campaign_queue
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',
@@ -659,6 +881,7 @@ const initDB = async () => {
}); });
await pool.query(`CREATE INDEX IF NOT EXISTS idx_stock_campaign_queue_status ON stock_campaign_queue (status);`); await pool.query(`CREATE INDEX IF NOT EXISTS idx_stock_campaign_queue_status ON stock_campaign_queue (status);`);
await pool.query(`CREATE INDEX IF NOT EXISTS idx_stock_graphs_balance_history_product_created ON stock_graphs_balance_history (produto_id, created_at DESC);`);
await pool.query(`CREATE INDEX IF NOT EXISTS idx_production_orders_status ON production_orders (status);`); await pool.query(`CREATE INDEX IF NOT EXISTS idx_production_orders_status ON production_orders (status);`);
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);`);

View File

@@ -5,6 +5,7 @@
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {
"start": "node index.js", "start": "node index.js",
"seed:supplies-demo": "node scripts/seedSuppliesDemo.js",
"test": "node --test" "test": "node --test"
}, },
"keywords": [], "keywords": [],

View File

@@ -12,7 +12,12 @@ const {
getProductDetailsAnalytics, getProductDetailsAnalytics,
getRfmAnalytics getRfmAnalytics
} = require('../services/analyticsService'); } = require('../services/analyticsService');
const { getProductComposition, listProductCompositions } = require('../services/productionOrderService'); const {
deleteManualProductComposition,
getProductComposition,
listProductCompositions,
saveManualProductComposition
} = require('../services/productionOrderService');
const router = express.Router(); const router = express.Router();
@@ -94,6 +99,22 @@ router.get('/analytics/products/:productId/composition', verifyToken, async (req
} }
}); });
router.put('/analytics/products/:productId/composition', verifyToken, async (req, res, next) => {
try {
res.json({ composition: await saveManualProductComposition(req.params.productId, req.body) });
} catch (error) {
next(error);
}
});
router.delete('/analytics/products/:productId/composition', verifyToken, async (req, res, next) => {
try {
res.json(await deleteManualProductComposition(req.params.productId));
} catch (error) {
next(error);
}
});
router.get('/analytics/product-compositions', verifyToken, async (req, res) => { router.get('/analytics/product-compositions', verifyToken, async (req, res) => {
try { try {
res.json({ compositions: await listProductCompositions() }); res.json({ compositions: await listProductCompositions() });

View File

@@ -1,5 +1,5 @@
const express = require('express'); const express = require('express');
const { verifySuperAdmin } = require('../auth'); const { verifyToken } = require('../auth');
const { const {
completeAuthorization, completeAuthorization,
createAuthorizationUrl, createAuthorizationUrl,
@@ -9,19 +9,22 @@ const {
requestOlistSyncStop, requestOlistSyncStop,
startOlistSync startOlistSync
} = require('../services/olistService'); } = require('../services/olistService');
const { importFinalizedProductionOrderCsv, listProductionOrderImportData } = require('../services/productionOrderCsvImportService'); const { exportMissingProductionOrderDataCsv, importFinalizedProductionOrderCsv, listProductionOrderImportData } = require('../services/productionOrderCsvImportService');
const router = express.Router(); const router = express.Router();
router.get('/olist/status', verifySuperAdmin, async (req, res, next) => { router.get('/olist/status', verifyToken, async (req, res, next) => {
try { try {
res.json(await getOlistStatus()); res.json(await getOlistStatus({
runsPage: req.query.runsPage,
runsPageSize: req.query.runsPageSize
}));
} catch (error) { } catch (error) {
next(error); next(error);
} }
}); });
router.post('/olist/authorization-url', verifySuperAdmin, async (req, res, next) => { router.post('/olist/authorization-url', verifyToken, async (req, res, next) => {
try { try {
res.json({ url: await createAuthorizationUrl() }); res.json({ url: await createAuthorizationUrl() });
} catch (error) { } catch (error) {
@@ -29,7 +32,7 @@ router.post('/olist/authorization-url', verifySuperAdmin, async (req, res, next)
} }
}); });
router.post('/olist/sync', verifySuperAdmin, async (req, res, next) => { router.post('/olist/sync', verifyToken, async (req, res, next) => {
try { try {
const result = await startOlistSync({ const result = await startOlistSync({
trigger: 'manual', trigger: 'manual',
@@ -41,7 +44,7 @@ router.post('/olist/sync', verifySuperAdmin, async (req, res, next) => {
} }
}); });
router.post('/olist/sync/stop', verifySuperAdmin, async (req, res, next) => { router.post('/olist/sync/stop', verifyToken, async (req, res, next) => {
try { try {
res.status(202).json(await requestOlistSyncStop()); res.status(202).json(await requestOlistSyncStop());
} catch (error) { } catch (error) {
@@ -49,15 +52,26 @@ router.post('/olist/sync/stop', verifySuperAdmin, async (req, res, next) => {
} }
}); });
router.get('/olist/production-order-imports', verifySuperAdmin, async (req, res, next) => { router.get('/olist/production-order-imports', verifyToken, async (req, res, next) => {
try { try {
res.json(await listProductionOrderImportData()); res.json(await listProductionOrderImportData({
runsPage: req.query.runsPage,
runsPageSize: req.query.runsPageSize
}));
} catch (error) { } catch (error) {
next(error); next(error);
} }
}); });
router.post('/olist/production-order-imports', verifySuperAdmin, async (req, res, next) => { router.get('/olist/production-order-imports/pending-csv', verifyToken, async (req, res, next) => {
try {
res.json(await exportMissingProductionOrderDataCsv());
} catch (error) {
next(error);
}
});
router.post('/olist/production-order-imports', verifyToken, async (req, res, next) => {
try { try {
res.status(201).json(await importFinalizedProductionOrderCsv(req.body || {})); res.status(201).json(await importFinalizedProductionOrderCsv(req.body || {}));
} catch (error) { } catch (error) {
@@ -65,7 +79,7 @@ router.post('/olist/production-order-imports', verifySuperAdmin, async (req, res
} }
}); });
router.get('/olist/runs/:runId', verifySuperAdmin, async (req, res, next) => { router.get('/olist/runs/:runId', verifyToken, async (req, res, next) => {
try { try {
res.json(await getOlistRunDetails(req.params.runId, { res.json(await getOlistRunDetails(req.params.runId, {
eventsPage: req.query.eventsPage, eventsPage: req.query.eventsPage,

View File

@@ -1,6 +1,6 @@
const express = require('express'); const express = require('express');
const { authenticateAPIKey, verifyToken } = require('../auth'); const { authenticateAPIKey, verifyToken } = require('../auth');
const { createProductionOrders, importTinyProductCompositionExport, listProductionOrders, updateProductionOrderStatus, upsertTinyProductionOrderDetail } = require('../services/productionOrderService'); const { addProductionOrderDefect, createProductionOrders, createProductionWorkstation, importTinyProductCompositionExport, listProductionOrders, listProductionPlans, listProductionWorkstations, saveProductionPlanReview, updateProductionOrderExecution, updateProductionOrderStatus, updateProductionPlan, updateProductionPlanStatus, updateProductionWorkstation, upsertTinyProductionOrderDetail } = require('../services/productionOrderService');
const router = express.Router(); const router = express.Router();
@@ -16,13 +16,73 @@ router.get('/production-orders', verifyToken, async (req, res) => {
router.post('/production-orders', verifyToken, async (req, res) => { router.post('/production-orders', verifyToken, async (req, res) => {
try { try {
const orders = Array.isArray(req.body?.orders) ? req.body.orders : []; const orders = Array.isArray(req.body?.orders) ? req.body.orders : [];
res.status(201).json(await createProductionOrders(orders)); const lotReservations = Array.isArray(req.body?.lotReservations) ? req.body.lotReservations : [];
res.status(201).json(await createProductionOrders(orders, lotReservations, req.body?.plan || null));
} catch (error) { } catch (error) {
console.error('Error creating production orders:', error); console.error('Error creating production orders:', error);
res.status(error.statusCode || 500).json({ error: error.message || 'Internal Server Error' }); res.status(error.statusCode || 500).json({ error: error.message || 'Internal Server Error' });
} }
}); });
router.get('/production-plans', verifyToken, async (req, res) => {
try {
res.json(await listProductionPlans());
} catch (error) {
console.error('Error fetching production plans:', error);
res.status(500).json({ error: 'Internal Server Error' });
}
});
router.get('/production-workstations', verifyToken, async (req, res) => {
try {
res.json(await listProductionWorkstations({ includeInactive: req.query?.includeInactive === 'true' }));
} catch (error) {
res.status(500).json({ error: 'Internal Server Error' });
}
});
router.post('/production-workstations', verifyToken, async (req, res) => {
try {
res.status(201).json(await createProductionWorkstation(req.body || {}));
} catch (error) {
res.status(error.statusCode || 500).json({ error: error.message || 'Internal Server Error' });
}
});
router.put('/production-workstations/:id', verifyToken, async (req, res) => {
try {
res.json(await updateProductionWorkstation(req.params.id, req.body || {}));
} catch (error) {
res.status(error.statusCode || 500).json({ error: error.message || 'Internal Server Error' });
}
});
router.put('/production-plans/:id', verifyToken, async (req, res) => {
try {
res.json(await updateProductionPlan(req.params.id, req.body || {}));
} catch (error) {
console.error('Error updating production plan:', error);
res.status(error.statusCode || 500).json({ error: error.message || 'Internal Server Error' });
}
});
router.patch('/production-plans/:id/status', verifyToken, async (req, res) => {
try {
res.json(await updateProductionPlanStatus(req.params.id, req.body?.status));
} catch (error) {
console.error('Error updating production plan status:', error);
res.status(error.statusCode || 500).json({ error: error.message || 'Internal Server Error' });
}
});
router.put('/production-plans/:id/review', verifyToken, async (req, res) => {
try {
res.json(await saveProductionPlanReview(req.params.id, req.body || {}));
} catch (error) {
res.status(error.statusCode || 500).json({ error: error.message || 'Internal Server Error' });
}
});
router.post('/production-orders/tiny-sync', authenticateAPIKey, async (req, res) => { router.post('/production-orders/tiny-sync', authenticateAPIKey, async (req, res) => {
try { try {
res.status(201).json(await upsertTinyProductionOrderDetail(req.body || {})); res.status(201).json(await upsertTinyProductionOrderDetail(req.body || {}));
@@ -50,4 +110,21 @@ router.patch('/production-orders/:id/status', verifyToken, async (req, res) => {
} }
}); });
router.patch('/production-orders/:id/execution', verifyToken, async (req, res) => {
try {
res.json(await updateProductionOrderExecution(req.params.id, req.body || {}));
} catch (error) {
console.error('Error updating production order execution:', error);
res.status(error.statusCode || 500).json({ error: error.message || 'Internal Server Error' });
}
});
router.post('/production-orders/:id/defects', verifyToken, async (req, res) => {
try {
res.status(201).json(await addProductionOrderDefect(req.params.id, req.body || {}));
} catch (error) {
res.status(error.statusCode || 500).json({ error: error.message || 'Internal Server Error' });
}
});
module.exports = router; module.exports = router;

View File

@@ -1,6 +1,6 @@
const express = require('express'); const express = require('express');
const { authenticateAPIKey, verifyToken } = require('../auth'); const { authenticateAPIKey, verifyToken } = require('../auth');
const { listStock, upsertStockItems } = require('../services/stockService'); const { listGraphsBalanceHistory, listStock, removeGraphsBalance, saveGraphsBalance, upsertStockItems } = require('../services/stockService');
const router = express.Router(); const router = express.Router();
@@ -13,6 +13,35 @@ router.get('/stock', verifyToken, async (req, res) => {
} }
}); });
router.get('/stock/:productId/graphs-balance-history', verifyToken, async (req, res) => {
try {
res.json(await listGraphsBalanceHistory(req.params.productId));
} catch (error) {
console.error('Error fetching Graphs stock balance history:', error);
res.status(500).json({ error: 'Internal Server Error' });
}
});
router.put('/stock/:productId/graphs-balance', verifyToken, async (req, res) => {
try {
const balance = await saveGraphsBalance(req.params.productId, req.body, req.user?.email);
res.json(balance);
} catch (error) {
console.error('Error saving Graphs stock balance:', error);
res.status(error.statusCode || 500).json({ error: error.message || 'Internal Server Error' });
}
});
router.delete('/stock/:productId/graphs-balance', verifyToken, async (req, res) => {
try {
const balance = await removeGraphsBalance(req.params.productId, req.user?.email);
res.json(balance);
} catch (error) {
console.error('Error removing Graphs stock balance:', error);
res.status(error.statusCode || 500).json({ error: error.message || 'Internal Server Error' });
}
});
router.post('/stock', authenticateAPIKey, async (req, res) => { router.post('/stock', authenticateAPIKey, async (req, res) => {
res.status(201).json({ message: 'Stock data received, processing in background' }); res.status(201).json({ message: 'Stock data received, processing in background' });

View File

@@ -5,12 +5,19 @@ const {
approveReceipt, approveReceipt,
consumeLotForProduction, consumeLotForProduction,
createFabricPlan, createFabricPlan,
createMaterialTransformation,
createPurchaseOrder,
createReceipt, createReceipt,
deleteFabricPlan, deleteFabricPlan,
deleteReceipt, deleteReceipt,
getSupplySummary, getSupplySummary,
listFabricPlans, listFabricPlans,
listMaterialTransformations,
listKnownSuppliers,
listPurchaseOrders,
saveMaterialDetails, saveMaterialDetails,
updateMaterialTransformationStatus,
updatePurchaseOrderStatus,
listLots, listLots,
listMovements, listMovements,
listPurchaseNeeds, listPurchaseNeeds,
@@ -27,6 +34,38 @@ router.get('/supply', verifyToken, async (req, res, next) => {
} }
}); });
router.get('/supply/suppliers', verifyToken, async (req, res, next) => {
try {
res.json(await listKnownSuppliers());
} catch (error) {
next(error);
}
});
router.get('/supply/purchase-orders', verifyToken, async (req, res, next) => {
try {
res.json(await listPurchaseOrders());
} catch (error) {
next(error);
}
});
router.post('/supply/purchase-orders', verifyToken, async (req, res, next) => {
try {
res.status(201).json(await createPurchaseOrder(req.body || {}));
} catch (error) {
next(error);
}
});
router.patch('/supply/purchase-orders/:id/status', verifyToken, async (req, res, next) => {
try {
res.json(await updatePurchaseOrderStatus(req.params.id, req.body?.status));
} catch (error) {
next(error);
}
});
router.get('/supply/receipts', verifyToken, async (req, res, next) => { router.get('/supply/receipts', verifyToken, async (req, res, next) => {
try { try {
res.json(await listReceipts()); res.json(await listReceipts());
@@ -117,6 +156,30 @@ router.delete('/supply/fabric-plans/:id', verifyToken, async (req, res, next) =>
} }
}); });
router.get('/supply/material-transformations', verifyToken, async (req, res, next) => {
try {
res.json(await listMaterialTransformations());
} catch (error) {
next(error);
}
});
router.post('/supply/material-transformations', verifyToken, async (req, res, next) => {
try {
res.status(201).json(await createMaterialTransformation(req.body || {}));
} catch (error) {
next(error);
}
});
router.patch('/supply/material-transformations/:id/status', verifyToken, async (req, res, next) => {
try {
res.json(await updateMaterialTransformationStatus(req.params.id, req.body?.status));
} catch (error) {
next(error);
}
});
router.get('/supply/purchase-needs', verifyToken, async (req, res, next) => { router.get('/supply/purchase-needs', verifyToken, async (req, res, next) => {
try { try {
res.json(await listPurchaseNeeds()); res.json(await listPurchaseNeeds());

View File

@@ -1,10 +1,18 @@
const express = require('express'); const express = require('express');
const { verifySuperAdmin } = require('../auth'); const { verifyToken } = require('../auth');
const { createUser, deleteUser, listUsers, updateUser } = require('../services/userService'); const { createUser, deleteUser, listActiveOperationalUsers, listUsers, updateUser } = require('../services/userService');
const router = express.Router(); const router = express.Router();
router.get('/users', verifySuperAdmin, async (req, res, next) => { router.get('/users/operational', verifyToken, async (req, res, next) => {
try {
res.json({ users: await listActiveOperationalUsers() });
} catch (error) {
next(error);
}
});
router.get('/users', verifyToken, async (req, res, next) => {
try { try {
const users = await listUsers(); const users = await listUsers();
res.json({ users }); res.json({ users });
@@ -13,7 +21,7 @@ router.get('/users', verifySuperAdmin, async (req, res, next) => {
} }
}); });
router.post('/users', verifySuperAdmin, async (req, res, next) => { router.post('/users', verifyToken, async (req, res, next) => {
try { try {
const { name, email, password } = req.body || {}; const { name, email, password } = req.body || {};
const { user, password: userPassword, generatedPassword } = await createUser({ name, email, password }); const { user, password: userPassword, generatedPassword } = await createUser({ name, email, password });
@@ -32,7 +40,7 @@ router.post('/users', verifySuperAdmin, async (req, res, next) => {
} }
}); });
router.patch('/users/:id', verifySuperAdmin, async (req, res, next) => { router.patch('/users/:id', verifyToken, async (req, res, next) => {
try { try {
const user = await updateUser(req.params.id, req.body || {}); const user = await updateUser(req.params.id, req.body || {});
res.json({ user }); res.json({ user });
@@ -41,7 +49,7 @@ router.patch('/users/:id', verifySuperAdmin, async (req, res, next) => {
} }
}); });
router.delete('/users/:id', verifySuperAdmin, async (req, res, next) => { router.delete('/users/:id', verifyToken, async (req, res, next) => {
try { try {
await deleteUser(req.params.id); await deleteUser(req.params.id);
res.status(204).send(); res.status(204).send();

View File

@@ -42,7 +42,7 @@ DATE_NUM_FMT_IDS = {
57, 57,
} }
CUT_SHEETS = {"BLCS", "BLMC", "BLOS", "BLPM"} CUT_SHEETS = {"BLCS", "BLMC", "BLOS", "BLPM", "BLRM"}
COLOR_WORDS = { COLOR_WORDS = {
"amarelo", "amarelo",
"azul", "azul",

View File

@@ -0,0 +1,543 @@
const { pool, initDB } = require('../db');
// This seed is deliberately isolated from Tiny/Olist data. It only creates or
// refreshes records with LOCAL-DEMO / DEMONSTRAÇÃO identifiers, so it is safe
// to run again in a local environment when the operational screens need data.
const DEMO = 'DEMONSTRAÇÃO';
const normalizeMaterialKey = (value) => String(value || '')
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase()
.trim()
.replace(/\s+/g, ' ');
const today = new Date().toISOString().slice(0, 10);
const offsetDate = (days) => {
const date = new Date();
date.setDate(date.getDate() + days);
return date.toISOString().slice(0, 10);
};
const getCategoryId = async (client, name) => {
const result = await client.query('SELECT id FROM catalog_categories WHERE name = $1', [name]);
return result.rows[0]?.id || null;
};
const upsertCatalogProduct = async (client, product) => {
const categoryId = await getCategoryId(client, product.category);
const result = await client.query(`
INSERT INTO catalog_products (type, sku, name, category_id, color, sizes, notes, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, CURRENT_TIMESTAMP)
ON CONFLICT (sku) DO UPDATE
SET type = EXCLUDED.type,
name = EXCLUDED.name,
category_id = EXCLUDED.category_id,
color = EXCLUDED.color,
sizes = EXCLUDED.sizes,
notes = EXCLUDED.notes,
updated_at = CURRENT_TIMESTAMP
RETURNING id;
`, [
product.type,
product.sku,
product.name,
categoryId,
product.color || null,
product.sizes || [],
`${DEMO} — registro local para visualizar o fluxo de Suprimentos.`
]);
return Number(result.rows[0].id);
};
const findCatalogProduct = async (client, sku) => {
const result = await client.query('SELECT id FROM catalog_products WHERE sku = $1', [sku]);
return result.rows[0] ? Number(result.rows[0].id) : null;
};
const upsertConsumptionReference = async (client, reference) => {
const existing = await client.query(`
SELECT id
FROM consumption_references
WHERE product_id = $1
AND COALESCE(material_product_id, 0) = COALESCE($2, 0)
AND COALESCE(color, '') = COALESCE($3, '')
AND COALESCE(source, 'manual') = 'demonstration'
LIMIT 1;
`, [reference.productId, reference.materialProductId, reference.color || null]);
const values = [
reference.productId,
reference.materialProductId,
reference.color || null,
reference.generalYield || null,
reference.consumptionQuantity || null,
reference.consumptionUnit || null
];
if (existing.rows[0]) {
await client.query(`
UPDATE consumption_references
SET general_yield = $4,
consumption_quantity = $5,
consumption_unit = $6,
updated_at = CURRENT_TIMESTAMP
WHERE id = $7;
`, [...values, existing.rows[0].id]);
return;
}
await client.query(`
INSERT INTO consumption_references (
product_id, material_product_id, color, general_yield,
consumption_quantity, consumption_unit, source
) VALUES ($1, $2, $3, $4, $5, $6, 'demonstration');
`, values);
};
const upsertPurchaseOrder = async (client, order) => {
const result = await client.query(`
INSERT INTO supply_purchase_orders (number, supplier, status, expected_date, notes, updated_at)
VALUES ($1, $2, $3, $4, $5, CURRENT_TIMESTAMP)
ON CONFLICT (number) DO UPDATE
SET supplier = EXCLUDED.supplier,
status = EXCLUDED.status,
expected_date = EXCLUDED.expected_date,
notes = EXCLUDED.notes,
updated_at = CURRENT_TIMESTAMP
RETURNING id;
`, [order.number, order.supplier, order.status, order.expectedDate, order.notes]);
const id = Number(result.rows[0].id);
for (const item of order.items) {
await client.query(`
INSERT INTO supply_purchase_order_items (purchase_order_id, material, quantity, unit, unit_cost)
SELECT $1, $2, $3, $4, $5
WHERE NOT EXISTS (
SELECT 1 FROM supply_purchase_order_items
WHERE purchase_order_id = $1 AND material = $2
);
`, [id, item.material, item.quantity, item.unit, item.unitCost]);
}
return id;
};
const upsertReceipt = async (client, receipt) => {
const existing = await client.query('SELECT id FROM supply_receipts WHERE invoice = $1 LIMIT 1', [receipt.invoice]);
if (existing.rows[0]) {
await client.query(`
UPDATE supply_receipts
SET category = $1, product = $2, quantity = $3, unit = $4,
supplier = $5, notes = $6, status = $7, purchase_order_id = $8,
approved_at = CASE WHEN $7 = 'approved' THEN COALESCE(approved_at, CURRENT_TIMESTAMP) ELSE NULL END,
updated_at = CURRENT_TIMESTAMP
WHERE id = $9;
`, [receipt.category, receipt.product, receipt.quantity, receipt.unit, receipt.supplier, receipt.notes, receipt.status, receipt.purchaseOrderId, existing.rows[0].id]);
return Number(existing.rows[0].id);
}
const result = await client.query(`
INSERT INTO supply_receipts (
category, product, quantity, unit, supplier, invoice, notes, status,
purchase_order_id, approved_at
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9,
CASE WHEN $8 = 'approved' THEN CURRENT_TIMESTAMP ELSE NULL END)
RETURNING id;
`, [receipt.category, receipt.product, receipt.quantity, receipt.unit, receipt.supplier, receipt.invoice, receipt.notes, receipt.status, receipt.purchaseOrderId]);
return Number(result.rows[0].id);
};
const ensureLotAndMovement = async (client, receiptId) => {
const receipt = await client.query(`
SELECT id, category, product, quantity, unit, supplier, invoice
FROM supply_receipts
WHERE id = $1 AND status = 'approved';
`, [receiptId]);
if (!receipt.rows[0]) return null;
const row = receipt.rows[0];
const lot = await client.query(`
INSERT INTO supply_stock_lots (receipt_id, category, product, quantity, unit, supplier, invoice, status)
SELECT $1, $2, $3, $4, $5, $6, $7, 'active'
WHERE NOT EXISTS (SELECT 1 FROM supply_stock_lots WHERE receipt_id = $1)
RETURNING id;
`, [row.id, row.category, row.product, row.quantity, row.unit, row.supplier, row.invoice]);
const lotId = lot.rows[0]?.id || (await client.query('SELECT id FROM supply_stock_lots WHERE receipt_id = $1', [row.id])).rows[0]?.id;
if (!lotId) return null;
await client.query(`
INSERT INTO supply_movements (receipt_id, lot_id, type, category, product, quantity, unit, reason)
SELECT $1, $2, 'receipt', $3, $4, $5, $6, $7
WHERE NOT EXISTS (
SELECT 1 FROM supply_movements
WHERE receipt_id = $1 AND type = 'receipt'
);
`, [row.id, lotId, row.category, row.product, row.quantity, row.unit, `${DEMO} — entrada aprovada para visualização.`]);
return Number(lotId);
};
const upsertProductionPlan = async (client, plan, responsibleUserId) => {
const result = await client.query(`
INSERT INTO production_plans (
number, name, status, contractor, responsible_user_id, responsible_name,
planned_start_date, expected_date, notes, source
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 'cut_plan')
ON CONFLICT (number) DO UPDATE
SET name = EXCLUDED.name,
status = EXCLUDED.status,
contractor = EXCLUDED.contractor,
responsible_user_id = EXCLUDED.responsible_user_id,
responsible_name = EXCLUDED.responsible_name,
planned_start_date = EXCLUDED.planned_start_date,
expected_date = EXCLUDED.expected_date,
notes = EXCLUDED.notes,
updated_at = CURRENT_TIMESTAMP
RETURNING id;
`, [
plan.number, plan.name, plan.status, plan.contractor, responsibleUserId,
plan.responsibleName, plan.plannedStartDate, plan.expectedDate, plan.notes
]);
return Number(result.rows[0].id);
};
const upsertProductionOrder = async (client, order) => {
const existing = await client.query('SELECT id FROM production_orders WHERE number = $1 LIMIT 1', [order.number]);
const values = [
order.status, order.planId, order.contractor, order.orderReference, order.issueDate,
order.expectedDate, order.productSku, order.productDescription, order.quantity, order.unit,
order.notes, order.supplier, order.lotCode, order.rollQuantity, order.fabricKg,
order.ribKg, order.yieldPiecesPerKg, order.actualCutQuantity, order.rejectedQuantity,
order.finishedQuantity, order.qualityNotes, order.cutLayers, order.markerReference
];
if (existing.rows[0]) {
await client.query(`
UPDATE production_orders
SET status = $1, production_plan_id = $2, contractor = $3, order_reference = $4,
issue_date = $5, expected_date = $6, product_sku = $7, product_description = $8,
quantity = $9, unit = $10, notes = $11, supplier = $12, lot_code = $13,
roll_quantity = $14, fabric_kg = $15, rib_kg = $16, yield_pieces_per_kg = $17,
actual_cut_quantity = $18, rejected_quantity = $19, finished_quantity = $20,
quality_notes = $21, cut_layers = $22, marker_reference = $23,
updated_at = CURRENT_TIMESTAMP
WHERE id = $24;
`, [...values, existing.rows[0].id]);
return Number(existing.rows[0].id);
}
const result = await client.query(`
INSERT INTO production_orders (
number, status, production_plan_id, contractor, order_reference, issue_date,
expected_date, product_sku, product_description, quantity, unit, notes, supplier,
lot_code, roll_quantity, fabric_kg, rib_kg, yield_pieces_per_kg,
actual_cut_quantity, rejected_quantity, finished_quantity, quality_notes,
cut_layers, marker_reference
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13,
$14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24)
RETURNING id;
`, [order.number, ...values]);
return Number(result.rows[0].id);
};
const ensureOrderDetails = async (client, orderId, details) => {
for (const marker of details.markers) {
await client.query(`
INSERT INTO production_order_markers (production_order_id, label, color)
SELECT $1, $2, $3
WHERE NOT EXISTS (
SELECT 1 FROM production_order_markers WHERE production_order_id = $1 AND label = $2
);
`, [orderId, marker.label, marker.color]);
}
for (const component of details.components) {
await client.query(`
INSERT INTO production_order_components (production_order_id, component_sku, component_name, quantity_per_unit, total_quantity, unit)
SELECT $1, $2, $3, $4, $5, $6
WHERE NOT EXISTS (
SELECT 1 FROM production_order_components
WHERE production_order_id = $1 AND component_name = $3
);
`, [orderId, component.sku, component.name, component.quantityPerUnit, component.totalQuantity, component.unit]);
}
for (const step of details.steps) {
await client.query(`
INSERT INTO production_order_steps (production_order_id, step_number, name, start_date, end_date, status, color)
SELECT $1, $2, $3, $4, $5, $6, $7
WHERE NOT EXISTS (
SELECT 1 FROM production_order_steps
WHERE production_order_id = $1 AND step_number = $2
);
`, [orderId, step.number, step.name, step.startDate, step.endDate, step.status, step.color]);
}
};
async function main() {
await initDB();
const client = await pool.connect();
try {
await client.query('BEGIN');
const materials = {
fabric: await upsertCatalogProduct(client, {
type: 'raw_material', sku: 'LOCAL-DEMO-MAT-MALHA-PRETA', category: 'Malha',
name: 'DEMO-MALHA-300 MALHA 30/1 COR PRETO', color: 'Preto'
}),
rib: await upsertCatalogProduct(client, {
type: 'raw_material', sku: 'LOCAL-DEMO-MAT-RIBANA-PRETA', category: 'Aviamentos',
name: 'DEMO-0201 RIBANA 2X1 COR PRETO', color: 'Preto'
}),
hoodieFabric: await upsertCatalogProduct(client, {
type: 'raw_material', sku: 'LOCAL-DEMO-MAT-MOLETOM-GRAFITE', category: 'Malha',
name: 'DEMO-MOLETOM 3 CABOS COR GRAFITE', color: 'Grafite'
}),
labelM: await upsertCatalogProduct(client, {
type: 'raw_material', sku: 'LOCAL-DEMO-MAT-ETIQUETA-M', category: 'Embalagens',
name: 'DEMO-ETIQUETA TAMANHO M', sizes: ['M']
}),
labelG: await upsertCatalogProduct(client, {
type: 'raw_material', sku: 'LOCAL-DEMO-MAT-ETIQUETA-G', category: 'Embalagens',
name: 'DEMO-ETIQUETA TAMANHO G', sizes: ['G']
})
};
const finishedProducts = {
teeM: await findCatalogProduct(client, 'LOCAL-DEMO-UX-TEE-M'),
teeG: await findCatalogProduct(client, 'LOCAL-DEMO-UX-TEE-G'),
hoodieM: await findCatalogProduct(client, 'LOCAL-DEMO-UX-HOODIE-M')
};
if (!Object.values(finishedProducts).every(Boolean)) {
throw new Error('Os produtos locais de demonstração esperados não foram encontrados.');
}
await Promise.all([
upsertConsumptionReference(client, { productId: finishedProducts.teeM, materialProductId: materials.fabric, color: 'Preto', consumptionQuantity: 0.185, consumptionUnit: 'kg' }),
upsertConsumptionReference(client, { productId: finishedProducts.teeM, materialProductId: materials.rib, color: 'Preto', consumptionQuantity: 0.01, consumptionUnit: 'kg' }),
upsertConsumptionReference(client, { productId: finishedProducts.teeM, materialProductId: materials.labelM, consumptionQuantity: 1, consumptionUnit: 'un.' }),
upsertConsumptionReference(client, { productId: finishedProducts.teeG, materialProductId: materials.fabric, color: 'Preto', consumptionQuantity: 0.2, consumptionUnit: 'kg' }),
upsertConsumptionReference(client, { productId: finishedProducts.teeG, materialProductId: materials.rib, color: 'Preto', consumptionQuantity: 0.011, consumptionUnit: 'kg' }),
upsertConsumptionReference(client, { productId: finishedProducts.teeG, materialProductId: materials.labelG, consumptionQuantity: 1, consumptionUnit: 'un.' }),
upsertConsumptionReference(client, { productId: finishedProducts.hoodieM, materialProductId: materials.hoodieFabric, color: 'Grafite', consumptionQuantity: 0.75, consumptionUnit: 'kg' }),
upsertConsumptionReference(client, { productId: finishedProducts.hoodieM, materialProductId: materials.rib, color: 'Grafite', consumptionQuantity: 0.045, consumptionUnit: 'kg' }),
upsertConsumptionReference(client, { productId: finishedProducts.hoodieM, materialProductId: materials.labelM, consumptionQuantity: 1, consumptionUnit: 'un.' })
]);
const supplier = `${DEMO} Têxteis`;
const materialDetails = [
['DEMO-MALHA-300 MALHA 30/1 COR PRETO', supplier, 'Malha reservada para a semana de demonstração.'],
['DEMO-0201 RIBANA 2X1 COR PRETO', supplier, 'Aviamento de gola e acabamento.'],
['DEMO-MOLETOM 3 CABOS COR GRAFITE', `${DEMO} Malharia`, 'Material para o plano de moletom.']
];
for (const [material, preferredSupplier, notes] of materialDetails) {
await client.query(`
INSERT INTO supply_material_details (material_key, display_name, preferred_supplier, notes, updated_at)
VALUES ($1, $2, $3, $4, CURRENT_TIMESTAMP)
ON CONFLICT (material_key) DO UPDATE
SET display_name = EXCLUDED.display_name,
preferred_supplier = EXCLUDED.preferred_supplier,
notes = EXCLUDED.notes,
updated_at = CURRENT_TIMESTAMP;
`, [normalizeMaterialKey(material), material, preferredSupplier, notes]);
}
const fabricPlans = [
['DEMO-MALHA-300 MALHA 30/1 COR PRETO', 'Preto', 120, supplier, 'Crítico'],
['DEMO-0201 RIBANA 2X1 COR PRETO', 'Preto', 24, supplier, 'Atenção'],
['DEMO-MOLETOM 3 CABOS COR GRAFITE', 'Grafite', 90, `${DEMO} Malharia`, 'Normal']
];
for (const [material, color, quantity, planSupplier, priority] of fabricPlans) {
await client.query(`
INSERT INTO supply_fabric_plans (material, color, quantity_kg, supplier, priority, status, updated_at)
SELECT $1, $2, $3, $4, $5, 'active', CURRENT_TIMESTAMP
WHERE NOT EXISTS (
SELECT 1 FROM supply_fabric_plans
WHERE material = $1 AND COALESCE(color, '') = COALESCE($2, '') AND status = 'active'
);
`, [material, color, quantity, planSupplier, priority]);
}
const poApproved = await upsertPurchaseOrder(client, {
number: 'OC-DEMO-0001', supplier, status: 'approved', expectedDate: offsetDate(-2),
notes: `${DEMO} — compra aprovada; o recebimento está disponível para conferência.`,
items: [{ material: 'DEMO-MALHA-300 MALHA 30/1 COR PRETO', quantity: 120, unit: 'kg', unitCost: 29.9 }]
});
const poSent = await upsertPurchaseOrder(client, {
number: 'OC-DEMO-0002', supplier, status: 'sent', expectedDate: offsetDate(4),
notes: `${DEMO} — pedido enviado ao fornecedor e aguardando chegada.`,
items: [{ material: 'DEMO-0201 RIBANA 2X1 COR PRETO', quantity: 24, unit: 'kg', unitCost: 34.5 }]
});
const poFinancial = await upsertPurchaseOrder(client, {
number: 'OC-DEMO-0003', supplier: `${DEMO} Etiquetas`, status: 'pending_financial', expectedDate: offsetDate(6),
notes: `${DEMO} — aguardando aprovação financeira; não libera saldo físico.`,
items: [{ material: 'DEMO-ETIQUETA TAMANHO M', quantity: 500, unit: 'un.', unitCost: 0.28 }]
});
const receiptIds = [];
receiptIds.push(await upsertReceipt(client, {
category: 'Malha', product: 'DEMO-MALHA-300 MALHA 30/1 COR PRETO', quantity: 120, unit: 'kg',
supplier, invoice: 'DEMO-NF-004', status: 'approved', purchaseOrderId: poApproved,
notes: `${DEMO} — recebimento conferido e saldo liberado.`
}));
receiptIds.push(await upsertReceipt(client, {
category: 'Aviamentos', product: 'DEMO-0201 RIBANA 2X1 COR PRETO', quantity: 24, unit: 'kg',
supplier, invoice: 'DEMO-NF-005', status: 'pending', purchaseOrderId: poSent,
notes: `${DEMO} — aguardando conferência física e aprovação da compra.`
}));
receiptIds.push(await upsertReceipt(client, {
category: 'Embalagens', product: 'DEMO-ETIQUETA TAMANHO M', quantity: 500, unit: 'un.',
supplier: `${DEMO} Etiquetas`, invoice: 'DEMO-NF-006', status: 'pending', purchaseOrderId: poFinancial,
notes: `${DEMO} — saldo bloqueado até a aprovação financeira da OC.`
}));
const lotIds = [];
for (const receiptId of receiptIds) {
const lotId = await ensureLotAndMovement(client, receiptId);
if (lotId) lotIds.push(lotId);
}
const transformations = [
['DEMO-LT-2408-01', 'tingimento', 'DEMO-MALHA-300 CRUA', 260, 'DEMO-MALHA-300 MALHA 30/1', 'Preto', 2, 254.8, `${DEMO} Tinturaria`, 'Atenção', 'in_progress', `${DEMO} — lote em tingimento.`],
['DEMO-LT-2408-02', 'acabamento', 'DEMO-MOLETOM 3 CABOS', 96, 'DEMO-MOLETOM 3 CABOS', 'Grafite', 1.5, 94.56, `${DEMO} Malharia`, 'Normal', 'planned', `${DEMO} — acabamento programado.`],
['DEMO-LT-2408-00', 'malharia', 'DEMO-FIO 30/1 PRETO', 180, 'DEMO-MALHA-300 MALHA 30/1', 'Preto', 3, 174.6, `${DEMO} Malharia`, 'Normal', 'completed', `${DEMO} — lote concluído para rastreabilidade.`]
];
for (const transformation of transformations) {
await client.query(`
INSERT INTO supply_material_transformations (
batch_code, stage, input_material, input_quantity_kg, output_material,
output_color, expected_loss_percent, expected_output_kg, supplier,
priority, status, notes, updated_at
) SELECT $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, CURRENT_TIMESTAMP
WHERE NOT EXISTS (
SELECT 1 FROM supply_material_transformations
WHERE batch_code = $1 AND stage = $2
);
`, transformation);
}
const user = await client.query(`
SELECT id, name FROM app_users WHERE is_active = TRUE ORDER BY id LIMIT 1;
`);
const responsibleUserId = user.rows[0]?.id || null;
const responsibleName = user.rows[0]?.name || 'Operação Graphs';
const planCut = await upsertProductionPlan(client, {
number: 'PP-DEMO-0001', name: `${DEMO} | Corte preto — semana operacional`, status: 'cutting',
contractor: `${DEMO} Confecção`, responsibleName, plannedStartDate: offsetDate(-1), expectedDate: offsetDate(3),
notes: `${DEMO} — plano liberado com lote e risco definidos.`
}, responsibleUserId);
const planHoodie = await upsertProductionPlan(client, {
number: 'PP-DEMO-0002', name: `${DEMO} | Moletom grafite — reposição`, status: 'open',
contractor: `${DEMO} Confecção`, responsibleName, plannedStartDate: offsetDate(3), expectedDate: offsetDate(8),
notes: `${DEMO} — aguardando liberação da etapa de malha.`
}, responsibleUserId);
await client.query(`
INSERT INTO production_plan_reviews (production_plan_id, decision, checklist, notes, reviewed_by, reviewed_at)
VALUES ($1, 'approved', $2::jsonb, $3, $4, CURRENT_TIMESTAMP)
ON CONFLICT (production_plan_id) DO UPDATE
SET decision = EXCLUDED.decision,
checklist = EXCLUDED.checklist,
notes = EXCLUDED.notes,
reviewed_by = EXCLUDED.reviewed_by,
reviewed_at = EXCLUDED.reviewed_at,
updated_at = CURRENT_TIMESTAMP;
`, [planCut, JSON.stringify({ material: true, yield: true, responsible: true, deadline: true }), `${DEMO} — checklist operacional concluído.`, responsibleName]);
const orderM = await upsertProductionOrder(client, {
number: 'OP-DEMO-0001', status: 'cutting', planId: planCut, contractor: `${DEMO} Confecção`,
orderReference: 'DEMO-CORTE-35', issueDate: offsetDate(-1), expectedDate: offsetDate(2),
productSku: 'LOCAL-DEMO-UX-TEE-M', productDescription: 'DEMO UX CAMISETA PRETA TAMANHO M',
quantity: 180, unit: 'UN', notes: `${DEMO} — acompanhar aproveitamento de risco.`, supplier,
lotCode: 'DEMO-NF-004', rollQuantity: 3, fabricKg: 33.3, ribKg: 1.8, yieldPiecesPerKg: 5.4,
actualCutQuantity: 150, rejectedQuantity: 2, finishedQuantity: 0,
qualityNotes: `${DEMO} — dois recortes separados para revisão de tecido.`, cutLayers: 12,
markerReference: 'RISCO-DEMO-M-12'
});
const orderG = await upsertProductionOrder(client, {
number: 'OP-DEMO-0002', status: 'sewing', planId: planCut, contractor: `${DEMO} Confecção`,
orderReference: 'DEMO-CORTE-35', issueDate: today, expectedDate: offsetDate(3),
productSku: 'LOCAL-DEMO-UX-TEE-G', productDescription: 'DEMO UX CAMISETA PRETA TAMANHO G',
quantity: 140, unit: 'UN', notes: `${DEMO} — lote encaminhado para costura.`, supplier,
lotCode: 'DEMO-NF-004', rollQuantity: 2, fabricKg: 28, ribKg: 1.54, yieldPiecesPerKg: 5,
actualCutQuantity: 140, rejectedQuantity: 1, finishedQuantity: 92,
qualityNotes: `${DEMO} — conferir gola nas primeiras peças.`, cutLayers: 9,
markerReference: 'RISCO-DEMO-G-09'
});
const orderHoodie = await upsertProductionOrder(client, {
number: 'OP-DEMO-0003', status: 'review', planId: planHoodie, contractor: `${DEMO} Confecção`,
orderReference: 'DEMO-MOLETOM-35', issueDate: offsetDate(2), expectedDate: offsetDate(8),
productSku: 'LOCAL-DEMO-UX-HOODIE-M', productDescription: 'DEMO UX MOLETOM GRAFITE TAMANHO M',
quantity: 60, unit: 'UN', notes: `${DEMO} — amostra segue para revisão antes da liberação total.`, supplier: `${DEMO} Malharia`,
lotCode: 'DEMO-LT-2408-02', rollQuantity: 2, fabricKg: 45, ribKg: 2.7, yieldPiecesPerKg: 1.33,
actualCutQuantity: 60, rejectedQuantity: 2, finishedQuantity: 52,
qualityNotes: `${DEMO} — registrar avarias por posto de trabalho.`, cutLayers: 6,
markerReference: 'RISCO-DEMO-MOLETOM-06'
});
await ensureOrderDetails(client, orderM, {
markers: [{ label: 'Risco M — 12 camadas', color: '#fbbf24' }],
components: [
{ sku: 'LOCAL-DEMO-MAT-MALHA-PRETA', name: 'DEMO-MALHA-300 MALHA 30/1 COR PRETO', quantityPerUnit: 0.185, totalQuantity: 33.3, unit: 'kg' },
{ sku: 'LOCAL-DEMO-MAT-RIBANA-PRETA', name: 'DEMO-0201 RIBANA 2X1 COR PRETO', quantityPerUnit: 0.01, totalQuantity: 1.8, unit: 'kg' }
],
steps: [
{ number: 1, name: 'Separação do lote', startDate: offsetDate(-1), endDate: offsetDate(-1), status: 'finished', color: '#22c55e' },
{ number: 2, name: 'Corte', startDate: today, endDate: offsetDate(1), status: 'in_progress', color: '#fbbf24' },
{ number: 3, name: 'Costura', startDate: offsetDate(1), endDate: offsetDate(2), status: 'pending', color: '#38bdf8' }
]
});
await ensureOrderDetails(client, orderG, {
markers: [{ label: 'Risco G — 9 camadas', color: '#38bdf8' }],
components: [{ sku: 'LOCAL-DEMO-MAT-MALHA-PRETA', name: 'DEMO-MALHA-300 MALHA 30/1 COR PRETO', quantityPerUnit: 0.2, totalQuantity: 28, unit: 'kg' }],
steps: [
{ number: 1, name: 'Corte', startDate: offsetDate(-1), endDate: today, status: 'finished', color: '#22c55e' },
{ number: 2, name: 'Costura', startDate: today, endDate: offsetDate(2), status: 'in_progress', color: '#38bdf8' },
{ number: 3, name: 'Revisão', startDate: offsetDate(2), endDate: offsetDate(3), status: 'pending', color: '#a78bfa' }
]
});
await ensureOrderDetails(client, orderHoodie, {
markers: [{ label: 'Risco moletom — 6 camadas', color: '#a78bfa' }],
components: [{ sku: 'LOCAL-DEMO-MAT-MOLETOM-GRAFITE', name: 'DEMO-MOLETOM 3 CABOS COR GRAFITE', quantityPerUnit: 0.75, totalQuantity: 45, unit: 'kg' }],
steps: [
{ number: 1, name: 'Corte', startDate: offsetDate(2), endDate: offsetDate(3), status: 'finished', color: '#22c55e' },
{ number: 2, name: 'Costura', startDate: offsetDate(3), endDate: offsetDate(6), status: 'finished', color: '#38bdf8' },
{ number: 3, name: 'Revisão', startDate: offsetDate(6), endDate: offsetDate(8), status: 'in_progress', color: '#a78bfa' }
]
});
const lotForReservation = lotIds[0];
if (lotForReservation) {
await client.query(`
INSERT INTO cut_plan_lot_reservations (
cut_plan_reference, production_plan_id, supply_lot_id, material_key,
quantity, consumed_quantity, status
) SELECT 'DEMO-CORTE-35', $1, $2, $3, 61.3, 0, 'active'
WHERE NOT EXISTS (
SELECT 1 FROM cut_plan_lot_reservations
WHERE cut_plan_reference = 'DEMO-CORTE-35' AND supply_lot_id = $2
);
`, [planCut, lotForReservation, normalizeMaterialKey('DEMO-MALHA-300 MALHA 30/1 COR PRETO')]);
}
const workstations = await client.query('SELECT id, code FROM production_workstations');
const workstationByCode = new Map(workstations.rows.map(row => [row.code, row.id]));
const defects = [
[orderM, workstationByCode.get('cutting'), 'Furo na malha', 'Tecido', 2, 'DEMO-NF-004', `${DEMO} — conferir rolo antes de seguir o corte.`],
[orderG, workstationByCode.get('sewing'), 'Costura desalinhada', 'Costura', 1, 'DEMO-NF-004', `${DEMO} — ajuste solicitado ao posto de costura.`],
[orderHoodie, workstationByCode.get('review'), 'Mancha de acabamento', 'Acabamento', 2, 'DEMO-LT-2408-02', `${DEMO} — lote separado para avaliação de qualidade.`]
];
for (const defect of defects) {
await client.query(`
INSERT INTO production_order_defects (
production_order_id, workstation_id, defect_type, origin, quantity, lot_code, notes
) SELECT $1, $2, $3, $4, $5, $6, $7
WHERE NOT EXISTS (
SELECT 1 FROM production_order_defects
WHERE production_order_id = $1 AND defect_type = $3 AND lot_code = $6
);
`, defect);
}
await client.query('COMMIT');
console.log('Demonstração de Suprimentos criada/atualizada com sucesso.');
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
await pool.end();
}
}
main().catch((error) => {
console.error('Não foi possível criar os dados de demonstração:', error.message);
process.exitCode = 1;
});

View File

@@ -1,6 +1,6 @@
const { pool } = require('../db'); const { pool } = require('../db');
const FAMILY_KEYS = ['BLCS', 'BLOS', 'BLMC', 'BLPM']; const FAMILY_KEYS = ['BLCS', 'BLOS', 'BLMC', 'BLPM', 'BLRM'];
const FAMILY_KEY_SET = new Set(FAMILY_KEYS); const FAMILY_KEY_SET = new Set(FAMILY_KEYS);
const PRODUCT_TYPE_KEYS = [ const PRODUCT_TYPE_KEYS = [
'finished_apparel', 'finished_apparel',

View File

@@ -102,13 +102,24 @@ const getConnection = async () => {
return result.rows[0] || null; return result.rows[0] || null;
}; };
const listSyncRuns = async (limit = 5) => { const listSyncRuns = async ({ page = 1, pageSize = 5 } = {}) => {
const result = await pool.query(` const normalizedPageSize = Math.max(1, Math.min(Number(pageSize) || 5, 20));
const normalizedPage = Math.max(1, Number(page) || 1);
const offset = (normalizedPage - 1) * normalizedPageSize;
const [runsResult, countResult] = await Promise.all([
pool.query(`
SELECT * FROM olist_sync_runs SELECT * FROM olist_sync_runs
ORDER BY started_at DESC ORDER BY started_at DESC
LIMIT $1; LIMIT $1 OFFSET $2;
`, [Math.max(1, Math.min(Number(limit) || 5, 20))]); `, [normalizedPageSize, offset]),
return result.rows.map(mapRun); pool.query('SELECT COUNT(*)::int AS count FROM olist_sync_runs;')
]);
return {
items: runsResult.rows.map(mapRun),
total: Number(countResult.rows[0]?.count || 0),
page: normalizedPage,
pageSize: normalizedPageSize
};
}; };
const hasScheduledSyncToday = async () => { const hasScheduledSyncToday = async () => {
@@ -252,8 +263,14 @@ const markInterruptedOlistRuns = async () => {
return result.rowCount || 0; return result.rowCount || 0;
}; };
const getOlistStatus = async () => { const getOlistStatus = async ({ runsPage = 1, runsPageSize = 5 } = {}) => {
const [connection, runs, productCache] = await Promise.all([getConnection(), listSyncRuns(), getProductCacheProgress()]); const [connection, syncRuns, productCache, latestRunResult, completedRunResult] = await Promise.all([
getConnection(),
listSyncRuns({ page: runsPage, pageSize: runsPageSize }),
getProductCacheProgress(),
pool.query('SELECT * FROM olist_sync_runs ORDER BY started_at DESC LIMIT 1;'),
pool.query("SELECT EXISTS (SELECT 1 FROM olist_sync_runs WHERE status = 'completed') AS exists;")
]);
return { return {
configured: isOlistConfigured(), configured: isOlistConfigured(),
connected: Boolean(connection), connected: Boolean(connection),
@@ -261,7 +278,12 @@ const getOlistStatus = async () => {
syncInProgress: Boolean(activeSync), syncInProgress: Boolean(activeSync),
nextScheduledSyncAt, nextScheduledSyncAt,
productCache, productCache,
runs latestRun: latestRunResult.rows[0] ? mapRun(latestRunResult.rows[0]) : null,
hasCompletedRun: Boolean(completedRunResult.rows[0]?.exists),
runs: syncRuns.items,
runsTotal: syncRuns.total,
runsPage: syncRuns.page,
runsPageSize: syncRuns.pageSize
}; };
}; };

View File

@@ -1,6 +1,6 @@
const { pool } = require('../db'); const { pool } = require('../db');
const FAMILY_KEYS = new Set(['BLCS', 'BLOS', 'BLMC', 'BLPM']); const FAMILY_KEYS = new Set(['BLCS', 'BLOS', 'BLMC', 'BLPM', 'BLRM']);
const REQUIRED_HEADERS = ['id_op', 'numero_op', 'sku_produto', 'descricao_produto', 'quantidade_produzida', 'data_emissao', 'data_conclusao', 'situacao', 'observacoes']; const REQUIRED_HEADERS = ['id_op', 'numero_op', 'sku_produto', 'descricao_produto', 'quantidade_produzida', 'data_emissao', 'data_conclusao', 'situacao', 'observacoes'];
const normalizeText = (value) => String(value || '').replace(/\s+/g, ' ').trim(); const normalizeText = (value) => String(value || '').replace(/\s+/g, ' ').trim();
@@ -31,6 +31,11 @@ const normalizeHeader = (header) => String(header || '')
.toLowerCase() .toLowerCase()
.replace(/\s+/g, '_'); .replace(/\s+/g, '_');
const csvCell = (value) => {
const normalized = value === null || value === undefined ? '' : String(value);
return /[",\r\n]/.test(normalized) ? `"${normalized.replace(/"/g, '""')}"` : normalized;
};
const parseCsv = (csv) => { const parseCsv = (csv) => {
const rows = []; const rows = [];
let row = []; let row = [];
@@ -71,12 +76,18 @@ const extractObservationFields = (notes) => {
const raw = String(notes || ''); const raw = String(notes || '');
const lineValue = (pattern) => raw.match(pattern)?.[1]?.trim() || ''; const lineValue = (pattern) => raw.match(pattern)?.[1]?.trim() || '';
const supplier = lineValue(/(?:^|\n)\s*FORNECEDOR\s*:\s*([^\r\n]+)/i); const supplier = lineValue(/(?:^|\n)\s*FORNECEDOR\s*:\s*([^\r\n]+)/i);
const lotCode = lineValue(/(?:^|\n)\s*LOTE\s*:\s*([^\r\n]+)/i); const lotCode = lineValue(/(?:^|\n)\s*(?:-\s*)?LOTE\s*:?[\t ]*([^\r\n]+)/i);
const yieldPiecesPerKg = normalizeNumber(raw.match(/RENDIMENTO\s*:\s*([\d.,]+)/i)?.[1]); const yieldMatch = raw.match(/RENDIMENTO\s*:\s*([\d.,]+)(?:\s*[-–]\s*([\d.,]+)\s*KG)?/i);
const explicitRolls = normalizeNumber(raw.match(/(?:QUANTIDADE\s+DE\s+ROLOS|ROLOS|BOBINAS)\s*:\s*([\d.,]+)/i)?.[1]); const yieldPiecesPerKg = normalizeNumber(yieldMatch?.[1]);
const bobbinMatch = raw.match(/(?:QUANTIDADE\s+DE\s+ROLOS|ROLOS|BOBINAS)\s*:\s*(\d+(?:[.,]\d+)?)(?:\s*\(\s*X\s*(\d+(?:[.,]\d+)?)\s*\))?(?:\s*[-–]\s*([\d.,]+)\s*KG)?/i);
const rollBase = normalizeNumber(bobbinMatch?.[1]);
const rollMultiplier = normalizeNumber(bobbinMatch?.[2]) || 1;
const explicitRolls = rollBase ? rollBase * rollMultiplier : null;
const fabricKg = normalizeNumber(raw.match(/(?:QUILOS?\s+DE\s+MALHA|QUILOS?\s+MALHA)\s*:\s*([\d.,]+)(?:\s*KG)?/i)?.[1]) const fabricKg = normalizeNumber(raw.match(/(?:QUILOS?\s+DE\s+MALHA|QUILOS?\s+MALHA)\s*:\s*([\d.,]+)(?:\s*KG)?/i)?.[1])
|| normalizeNumber(raw.match(/(?:ROLOS|BOBINAS)\s*:\s*[\d.,]+\s*[-–]\s*([\d.,]+)\s*KG/i)?.[1]); || normalizeNumber(bobbinMatch?.[3])
const ribKg = normalizeNumber(raw.match(/(?:QUILOS?\s+DE\s+(?:GOLA|RIBANA|VIEZ(?:\s*\([^)]*\))?))\s*:\s*([\d.,]+)(?:\s*KG)?/i)?.[1]); || normalizeNumber(yieldMatch?.[2])
|| normalizeNumber(raw.match(/(?:^|\n)\s*KG\s*:\s*([\d.,]+)(?:\s*KG)?/i)?.[1]);
const ribKg = normalizeNumber(raw.match(/(?:QUILOS?\s+DE\s+(?:GOLA|RIBANA|VIEZ(?:\s*\([^)]*\))?)|RIBANA)\s*:\s*([\d.,]+)(?:\s*KG)?/i)?.[1]);
return { supplier, lotCode, yieldPiecesPerKg, rollQuantity: explicitRolls, fabricKg, ribKg }; return { supplier, lotCode, yieldPiecesPerKg, rollQuantity: explicitRolls, fabricKg, ribKg };
}; };
@@ -129,6 +140,12 @@ const parseFinalizedProductionOrderCsv = (csv) => {
const familyKey = productSku.split('.')[0]; const familyKey = productSku.split('.')[0];
const extracted = extractObservationFields(notes); const extracted = extractObservationFields(notes);
const supplier = normalizeText(value('fornecedor')) || extracted.supplier;
const lotCode = normalizeText(value('lote')) || extracted.lotCode;
const rollQuantity = normalizeNumber(value('quantidade_rolos')) || extracted.rollQuantity;
const fabricKg = normalizeNumber(value('quilos_malha')) || extracted.fabricKg;
const ribKg = normalizeNumber(value('quilos_ribana')) || extracted.ribKg;
const yieldPiecesPerKg = normalizeNumber(value('rendimento')) || extracted.yieldPiecesPerKg;
records.push({ records.push({
tinyId, tinyId,
number, number,
@@ -140,7 +157,12 @@ const parseFinalizedProductionOrderCsv = (csv) => {
orderReference: normalizeText(value('pedido_relacionado')), orderReference: normalizeText(value('pedido_relacionado')),
notes, notes,
familyKey: FAMILY_KEYS.has(familyKey) ? familyKey : null, familyKey: FAMILY_KEYS.has(familyKey) ? familyKey : null,
...extracted supplier,
lotCode,
rollQuantity,
fabricKg,
ribKg,
yieldPiecesPerKg
}); });
}); });
@@ -149,6 +171,7 @@ const parseFinalizedProductionOrderCsv = (csv) => {
const refreshYieldRecommendations = async (client) => { const refreshYieldRecommendations = async (client) => {
await client.query('DELETE FROM production_yield_recommendations;'); await client.query('DELETE FROM production_yield_recommendations;');
await client.query('DELETE FROM production_yield_supplier_recommendations;');
await client.query(` await client.query(`
INSERT INTO production_yield_recommendations ( INSERT INTO production_yield_recommendations (
family_key, sample_count, yield_sample_count, roll_sample_count, family_key, sample_count, yield_sample_count, roll_sample_count,
@@ -162,21 +185,48 @@ const refreshYieldRecommendations = async (client) => {
COUNT(*) FILTER (WHERE fabric_kg > 0 AND roll_quantity > 0)::int, COUNT(*) FILTER (WHERE fabric_kg > 0 AND roll_quantity > 0)::int,
AVG(yield_pieces_per_kg), AVG(yield_pieces_per_kg),
AVG(fabric_kg / roll_quantity) FILTER (WHERE fabric_kg > 0 AND roll_quantity > 0), AVG(fabric_kg / roll_quantity) FILTER (WHERE fabric_kg > 0 AND roll_quantity > 0),
AVG(yield_pieces_per_kg) FILTER (WHERE fabric_kg > 0 AND roll_quantity > 0 AND yield_pieces_per_kg > 0) AVG(yield_pieces_per_kg * (fabric_kg / roll_quantity)) FILTER (WHERE fabric_kg > 0 AND roll_quantity > 0 AND yield_pieces_per_kg > 0),
* AVG(fabric_kg / roll_quantity) FILTER (WHERE fabric_kg > 0 AND roll_quantity > 0 AND yield_pieces_per_kg > 0),
MAX(completed_date), MAX(completed_date),
CURRENT_TIMESTAMP CURRENT_TIMESTAMP
FROM production_orders FROM production_orders
WHERE status = 'finished' WHERE status = 'finished'
AND split_part(product_sku, '.', 1) IN ('BLCS', 'BLOS', 'BLMC', 'BLPM') AND split_part(product_sku, '.', 1) IN ('BLCS', 'BLOS', 'BLMC', 'BLPM', 'BLRM')
GROUP BY split_part(product_sku, '.', 1); GROUP BY split_part(product_sku, '.', 1);
`); `);
await client.query(`
INSERT INTO production_yield_supplier_recommendations (
family_key, supplier, sample_count, yield_sample_count, roll_sample_count,
avg_yield_pieces_per_kg, avg_kg_per_roll, recommended_units_per_roll,
last_completed_date, updated_at
)
SELECT
split_part(product_sku, '.', 1) AS family_key,
supplier,
COUNT(*)::int,
COUNT(yield_pieces_per_kg)::int,
COUNT(*) FILTER (WHERE fabric_kg > 0 AND roll_quantity > 0)::int,
AVG(yield_pieces_per_kg),
AVG(fabric_kg / roll_quantity) FILTER (WHERE fabric_kg > 0 AND roll_quantity > 0),
AVG(yield_pieces_per_kg * (fabric_kg / roll_quantity)) FILTER (WHERE fabric_kg > 0 AND roll_quantity > 0 AND yield_pieces_per_kg > 0),
MAX(completed_date),
CURRENT_TIMESTAMP
FROM production_orders
WHERE status = 'finished'
AND supplier IS NOT NULL
AND split_part(product_sku, '.', 1) IN ('BLCS', 'BLOS', 'BLMC', 'BLPM', 'BLRM')
GROUP BY split_part(product_sku, '.', 1), supplier;
`);
}; };
const listProductionOrderImportData = async () => { const listProductionOrderImportData = async ({ runsPage = 1, runsPageSize = 5 } = {}) => {
const [runs, recommendations] = await Promise.all([ const normalizedRunsPageSize = Math.max(1, Math.min(Number(runsPageSize) || 5, 20));
pool.query(`SELECT id, filename, status, total_rows, imported_count, updated_count, skipped_count, failed_count, error_message, started_at, completed_at FROM production_order_import_runs ORDER BY started_at DESC LIMIT 10;`), const normalizedRunsPage = Math.max(1, Number(runsPage) || 1);
pool.query(`SELECT family_key, sample_count, yield_sample_count, roll_sample_count, avg_yield_pieces_per_kg, avg_kg_per_roll, recommended_units_per_roll, last_completed_date, updated_at FROM production_yield_recommendations ORDER BY family_key;`) const runsOffset = (normalizedRunsPage - 1) * normalizedRunsPageSize;
const [runs, runsCount, recommendations, supplierRecommendations] = await Promise.all([
pool.query(`SELECT id, filename, status, total_rows, imported_count, updated_count, skipped_count, failed_count, error_message, started_at, completed_at FROM production_order_import_runs ORDER BY started_at DESC LIMIT $1 OFFSET $2;`, [normalizedRunsPageSize, runsOffset]),
pool.query('SELECT COUNT(*)::int AS count FROM production_order_import_runs;'),
pool.query(`SELECT family_key, sample_count, yield_sample_count, roll_sample_count, avg_yield_pieces_per_kg, avg_kg_per_roll, recommended_units_per_roll, last_completed_date, updated_at FROM production_yield_recommendations ORDER BY family_key;`),
pool.query(`SELECT family_key, supplier, sample_count, yield_sample_count, roll_sample_count, avg_yield_pieces_per_kg, avg_kg_per_roll, recommended_units_per_roll, last_completed_date, updated_at FROM production_yield_supplier_recommendations ORDER BY family_key, supplier;`)
]); ]);
return { return {
runs: runs.rows.map(row => ({ runs: runs.rows.map(row => ({
@@ -185,16 +235,66 @@ const listProductionOrderImportData = async () => {
failed: row.failed_count, errorMessage: row.error_message || '', startedAt: row.started_at, failed: row.failed_count, errorMessage: row.error_message || '', startedAt: row.started_at,
completedAt: row.completed_at completedAt: row.completed_at
})), })),
runsTotal: Number(runsCount.rows[0]?.count || 0),
runsPage: normalizedRunsPage,
runsPageSize: normalizedRunsPageSize,
recommendations: recommendations.rows.map(row => ({ recommendations: recommendations.rows.map(row => ({
familyKey: row.family_key, sampleCount: Number(row.sample_count), yieldSampleCount: Number(row.yield_sample_count), familyKey: row.family_key, sampleCount: Number(row.sample_count), yieldSampleCount: Number(row.yield_sample_count),
rollSampleCount: Number(row.roll_sample_count), avgYieldPiecesPerKg: row.avg_yield_pieces_per_kg === null ? null : Number(row.avg_yield_pieces_per_kg), rollSampleCount: Number(row.roll_sample_count), avgYieldPiecesPerKg: row.avg_yield_pieces_per_kg === null ? null : Number(row.avg_yield_pieces_per_kg),
avgKgPerRoll: row.avg_kg_per_roll === null ? null : Number(row.avg_kg_per_roll), avgKgPerRoll: row.avg_kg_per_roll === null ? null : Number(row.avg_kg_per_roll),
recommendedUnitsPerRoll: row.recommended_units_per_roll === null ? null : Number(row.recommended_units_per_roll), recommendedUnitsPerRoll: row.recommended_units_per_roll === null ? null : Number(row.recommended_units_per_roll),
lastCompletedDate: row.last_completed_date || null, updatedAt: row.updated_at lastCompletedDate: row.last_completed_date || null, updatedAt: row.updated_at
})),
supplierRecommendations: supplierRecommendations.rows.map(row => ({
familyKey: row.family_key, supplier: row.supplier, sampleCount: Number(row.sample_count),
yieldSampleCount: Number(row.yield_sample_count), rollSampleCount: Number(row.roll_sample_count),
avgYieldPiecesPerKg: row.avg_yield_pieces_per_kg === null ? null : Number(row.avg_yield_pieces_per_kg),
avgKgPerRoll: row.avg_kg_per_roll === null ? null : Number(row.avg_kg_per_roll),
recommendedUnitsPerRoll: row.recommended_units_per_roll === null ? null : Number(row.recommended_units_per_roll),
lastCompletedDate: row.last_completed_date || null, updatedAt: row.updated_at
})) }))
}; };
}; };
const exportMissingProductionOrderDataCsv = async () => {
const result = await pool.query(`
SELECT tiny_id, number, product_sku, product_description, quantity, issue_date, completed_date, notes,
order_reference, supplier, lot_code, roll_quantity, fabric_kg, rib_kg, yield_pieces_per_kg
FROM production_orders
WHERE status = 'finished'
AND (
supplier IS NULL OR lot_code IS NULL OR roll_quantity IS NULL
OR fabric_kg IS NULL OR yield_pieces_per_kg IS NULL
)
ORDER BY completed_date DESC NULLS LAST, id DESC;
`);
const headers = [
'id_op', 'numero_op', 'sku_produto', 'descricao_produto', 'quantidade_produzida', 'data_emissao',
'data_conclusao', 'situacao', 'observacoes', 'pedido_relacionado', 'fornecedor', 'lote',
'quantidade_rolos', 'quilos_malha', 'quilos_ribana', 'rendimento', 'campos_pendentes'
];
const rows = result.rows.map((row) => {
const missing = [
!row.supplier && 'fornecedor',
!row.lot_code && 'lote',
!row.roll_quantity && 'quantidade_rolos',
!row.fabric_kg && 'quilos_malha',
!row.yield_pieces_per_kg && 'rendimento'
].filter(Boolean).join(', ');
return [
row.tiny_id, row.number, row.product_sku, row.product_description, row.quantity, row.issue_date,
row.completed_date, 'Finalizada', row.notes, row.order_reference, row.supplier, row.lot_code,
row.roll_quantity, row.fabric_kg, row.rib_kg, row.yield_pieces_per_kg, missing
];
});
return {
filename: `ops-finalizadas-pendentes-${new Date().toISOString().slice(0, 10)}.csv`,
totalRows: rows.length,
csv: [headers, ...rows].map(row => row.map(csvCell).join(',')).join('\r\n')
};
};
const importFinalizedProductionOrderCsv = async ({ filename, csv }) => { const importFinalizedProductionOrderCsv = async ({ filename, csv }) => {
const parsed = parseFinalizedProductionOrderCsv(csv); const parsed = parseFinalizedProductionOrderCsv(csv);
const client = await pool.connect(); const client = await pool.connect();
@@ -260,6 +360,7 @@ const importFinalizedProductionOrderCsv = async ({ filename, csv }) => {
}; };
module.exports = { module.exports = {
exportMissingProductionOrderDataCsv,
extractObservationFields, extractObservationFields,
importFinalizedProductionOrderCsv, importFinalizedProductionOrderCsv,
listProductionOrderImportData, listProductionOrderImportData,

File diff suppressed because it is too large Load Diff

View File

@@ -5,10 +5,180 @@ const { enqueueStockCampaignItem } = require('./campaignService');
const POSITIVE_STOCK_DELTA_THRESHOLD = 1; const POSITIVE_STOCK_DELTA_THRESHOLD = 1;
const listStock = async () => { const listStock = async () => {
const result = await pool.query('SELECT * FROM stock'); const result = await pool.query(`
SELECT
stock.*,
graphs.balance::float8 AS graphs_balance,
graphs.reason AS graphs_balance_reason,
graphs.effective_at AS graphs_balance_effective_at,
graphs.updated_by AS graphs_balance_updated_by,
graphs.updated_at AS graphs_balance_updated_at,
COALESCE(graphs.balance, stock.saldo)::float8 AS planning_balance
FROM stock
LEFT JOIN stock_graphs_balances graphs ON graphs.produto_id = stock.produto_id
ORDER BY stock.nome, stock.produto_id;
`);
return result.rows; return result.rows;
}; };
const getStockBalance = async (produtoId, client = pool) => {
const result = await client.query(`
SELECT
stock.*,
graphs.balance::float8 AS graphs_balance,
graphs.reason AS graphs_balance_reason,
graphs.effective_at AS graphs_balance_effective_at,
graphs.updated_by AS graphs_balance_updated_by,
graphs.updated_at AS graphs_balance_updated_at,
COALESCE(graphs.balance, stock.saldo)::float8 AS planning_balance
FROM stock
LEFT JOIN stock_graphs_balances graphs ON graphs.produto_id = stock.produto_id
WHERE stock.produto_id = $1;
`, [produtoId]);
return result.rows[0] || null;
};
const listGraphsBalanceHistory = async (produtoId) => {
const result = await pool.query(`
SELECT
id,
produto_id,
tiny_balance::float8 AS tiny_balance,
graphs_balance::float8 AS graphs_balance,
reason,
effective_at,
action,
changed_by,
created_at
FROM stock_graphs_balance_history
WHERE produto_id = $1
ORDER BY created_at DESC, id DESC
LIMIT 20;
`, [produtoId]);
return result.rows;
};
const normalizeGraphsBalancePayload = (payload) => {
const balance = Number(payload?.balance);
const reason = String(payload?.reason || '').trim();
const effectiveAt = String(payload?.effectiveAt || '').trim() || null;
if (!Number.isFinite(balance)) {
const error = new Error('Informe um saldo físico válido para o Graphs.');
error.statusCode = 400;
throw error;
}
if (!reason) {
const error = new Error('Informe o motivo da conferência de saldo.');
error.statusCode = 400;
throw error;
}
if (effectiveAt && !/^\d{4}-\d{2}-\d{2}$/.test(effectiveAt)) {
const error = new Error('Informe uma data de referência válida.');
error.statusCode = 400;
throw error;
}
return { balance, reason, effectiveAt };
};
const saveGraphsBalance = async (produtoId, payload, updatedBy) => {
const normalizedProductId = String(produtoId || '').trim();
if (!normalizedProductId) {
const error = new Error('Material inválido para ajuste de saldo.');
error.statusCode = 400;
throw error;
}
const { balance, reason, effectiveAt } = normalizeGraphsBalancePayload(payload);
const client = await pool.connect();
try {
await client.query('BEGIN');
const stock = await client.query('SELECT saldo FROM stock WHERE produto_id = $1 FOR UPDATE', [normalizedProductId]);
if (!stock.rows[0]) {
const error = new Error('Material não encontrado no estoque importado.');
error.statusCode = 404;
throw error;
}
await client.query(`
INSERT INTO stock_graphs_balances (produto_id, balance, reason, effective_at, updated_by)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (produto_id) DO UPDATE SET
balance = EXCLUDED.balance,
reason = EXCLUDED.reason,
effective_at = EXCLUDED.effective_at,
updated_by = EXCLUDED.updated_by,
updated_at = CURRENT_TIMESTAMP;
`, [normalizedProductId, balance, reason, effectiveAt, updatedBy || null]);
await client.query(`
INSERT INTO stock_graphs_balance_history (
produto_id, tiny_balance, graphs_balance, reason, effective_at, action, changed_by
) VALUES ($1, $2, $3, $4, $5, 'set', $6);
`, [normalizedProductId, stock.rows[0].saldo, balance, reason, effectiveAt, updatedBy || null]);
const saved = await getStockBalance(normalizedProductId, client);
await client.query('COMMIT');
return saved;
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
};
const removeGraphsBalance = async (produtoId, updatedBy) => {
const normalizedProductId = String(produtoId || '').trim();
const client = await pool.connect();
try {
await client.query('BEGIN');
const current = await client.query(`
SELECT stock.saldo, graphs.balance, graphs.reason, graphs.effective_at
FROM stock
LEFT JOIN stock_graphs_balances graphs ON graphs.produto_id = stock.produto_id
WHERE stock.produto_id = $1
FOR UPDATE OF stock;
`, [normalizedProductId]);
if (!current.rows[0]) {
const error = new Error('Material não encontrado no estoque importado.');
error.statusCode = 404;
throw error;
}
if (current.rows[0].balance === null) {
const error = new Error('Não há saldo Graphs para remover.');
error.statusCode = 404;
throw error;
}
await client.query('DELETE FROM stock_graphs_balances WHERE produto_id = $1', [normalizedProductId]);
await client.query(`
INSERT INTO stock_graphs_balance_history (
produto_id, tiny_balance, graphs_balance, reason, effective_at, action, changed_by
) VALUES ($1, $2, $3, $4, $5, 'removed', $6);
`, [
normalizedProductId,
current.rows[0].saldo,
current.rows[0].balance,
current.rows[0].reason,
current.rows[0].effective_at,
updatedBy || null
]);
const saved = await getStockBalance(normalizedProductId, client);
await client.query('COMMIT');
return saved;
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
};
const upsertStockItems = async (payload) => { const upsertStockItems = async (payload) => {
const client = await pool.connect(); const client = await pool.connect();
@@ -52,5 +222,8 @@ const upsertStockItems = async (payload) => {
module.exports = { module.exports = {
listStock, listStock,
listGraphsBalanceHistory,
saveGraphsBalance,
removeGraphsBalance,
upsertStockItems upsertStockItems
}; };

View File

@@ -46,6 +46,9 @@ const mapReceipt = (row) => ({
invoice: row.invoice || '', invoice: row.invoice || '',
notes: row.notes || '', notes: row.notes || '',
status: row.status, status: row.status,
purchaseOrderId: row.purchase_order_id === null || row.purchase_order_id === undefined ? null : Number(row.purchase_order_id),
purchaseOrderNumber: row.purchase_order_number || '',
purchaseOrderStatus: row.purchase_order_status || '',
createdAt: row.created_at, createdAt: row.created_at,
updatedAt: row.updated_at, updatedAt: row.updated_at,
approvedAt: row.approved_at approvedAt: row.approved_at
@@ -61,6 +64,8 @@ const mapLot = (row) => ({
supplier: row.supplier || '', supplier: row.supplier || '',
invoice: row.invoice || '', invoice: row.invoice || '',
status: row.status, status: row.status,
reservedQuantity: Number(row.reserved_quantity || 0),
availableQuantity: Number(row.available_quantity === undefined ? row.quantity : row.available_quantity),
createdAt: row.created_at, createdAt: row.created_at,
updatedAt: row.updated_at updatedAt: row.updated_at
}); });
@@ -90,6 +95,24 @@ const mapFabricPlan = (row) => ({
updatedAt: row.updated_at updatedAt: row.updated_at
}); });
const mapMaterialTransformation = (row) => ({
id: row.id,
batchCode: row.batch_code || '',
stage: row.stage,
inputMaterial: row.input_material,
inputQuantityKg: Number(row.input_quantity_kg),
outputMaterial: row.output_material,
outputColor: row.output_color || '',
expectedLossPercent: Number(row.expected_loss_percent || 0),
expectedOutputKg: Number(row.expected_output_kg || 0),
supplier: row.supplier || '',
priority: row.priority || 'Normal',
status: row.status || 'planned',
notes: row.notes || '',
createdAt: row.created_at,
updatedAt: row.updated_at
});
const mapMaterialDetails = (row) => ({ const mapMaterialDetails = (row) => ({
materialKey: row.material_key, materialKey: row.material_key,
displayName: row.display_name || '', displayName: row.display_name || '',
@@ -99,6 +122,27 @@ const mapMaterialDetails = (row) => ({
updatedAt: row.updated_at updatedAt: row.updated_at
}); });
const PURCHASE_ORDER_STATUSES = new Set(['draft', 'pending_financial', 'sent', 'approved', 'received', 'canceled']);
const mapPurchaseOrder = (row) => ({
id: row.id,
number: row.number || '',
supplier: row.supplier || '',
status: row.status || 'draft',
expectedDate: row.expected_date ? String(row.expected_date).slice(0, 10) : null,
notes: row.notes || '',
total: Number(row.total || 0),
items: Array.isArray(row.items) ? row.items.map(item => ({
id: item.id,
material: item.material || '',
quantity: Number(item.quantity || 0),
unit: item.unit || 'kg',
unitCost: item.unit_cost === null || item.unit_cost === undefined ? null : Number(item.unit_cost)
})) : [],
createdAt: row.created_at,
updatedAt: row.updated_at
});
const createValidationError = (message) => { const createValidationError = (message) => {
const error = new Error(message); const error = new Error(message);
error.statusCode = 400; error.statusCode = 400;
@@ -107,19 +151,31 @@ const createValidationError = (message) => {
const listReceipts = async () => { const listReceipts = async () => {
const result = await pool.query(` const result = await pool.query(`
SELECT id, category, product, quantity, unit, supplier, invoice, notes, status, created_at, updated_at, approved_at SELECT receipt.id, receipt.category, receipt.product, receipt.quantity, receipt.unit, receipt.supplier, receipt.invoice,
FROM supply_receipts receipt.notes, receipt.status, receipt.purchase_order_id, receipt.created_at, receipt.updated_at, receipt.approved_at,
ORDER BY created_at DESC, id DESC purchase_order.number AS purchase_order_number, purchase_order.status AS purchase_order_status
FROM supply_receipts receipt
LEFT JOIN supply_purchase_orders purchase_order ON purchase_order.id = receipt.purchase_order_id
ORDER BY receipt.created_at DESC, receipt.id DESC
`); `);
return result.rows.map(mapReceipt); return result.rows.map(mapReceipt);
}; };
const listLots = async () => { const listLots = async () => {
const result = await pool.query(` const result = await pool.query(`
SELECT id, receipt_id, category, product, quantity, unit, supplier, invoice, status, created_at, updated_at SELECT
FROM supply_stock_lots lot.id, lot.receipt_id, lot.category, lot.product, lot.quantity, lot.unit, lot.supplier, lot.invoice, lot.status, lot.created_at, lot.updated_at,
WHERE status = 'active' COALESCE(reservations.reserved_quantity, 0) AS reserved_quantity,
ORDER BY created_at DESC, id DESC GREATEST(0, lot.quantity - COALESCE(reservations.reserved_quantity, 0)) AS available_quantity
FROM supply_stock_lots lot
LEFT JOIN LATERAL (
SELECT COALESCE(SUM(quantity - consumed_quantity), 0) AS reserved_quantity
FROM cut_plan_lot_reservations reservation
WHERE reservation.supply_lot_id = lot.id
AND reservation.status = 'active'
) reservations ON TRUE
WHERE lot.status = 'active'
ORDER BY lot.created_at DESC, lot.id DESC
`); `);
return result.rows.map(mapLot); return result.rows.map(mapLot);
}; };
@@ -150,6 +206,19 @@ const listFabricPlans = async () => {
return result.rows.map(mapFabricPlan); return result.rows.map(mapFabricPlan);
}; };
const listMaterialTransformations = async () => {
const result = await pool.query(`
SELECT id, batch_code, stage, input_material, input_quantity_kg, output_material, output_color,
expected_loss_percent, expected_output_kg, supplier, priority, status, notes, created_at, updated_at
FROM supply_material_transformations
ORDER BY
CASE status WHEN 'in_progress' THEN 1 WHEN 'planned' THEN 2 WHEN 'completed' THEN 3 ELSE 4 END,
CASE priority WHEN 'Crítico' THEN 1 WHEN 'Atenção' THEN 2 ELSE 3 END,
created_at DESC, id DESC
`);
return result.rows.map(mapMaterialTransformation);
};
const listMaterialDetails = async () => { const listMaterialDetails = async () => {
const result = await pool.query(` const result = await pool.query(`
SELECT material_key, display_name, preferred_supplier, notes, created_at, updated_at SELECT material_key, display_name, preferred_supplier, notes, created_at, updated_at
@@ -158,6 +227,87 @@ const listMaterialDetails = async () => {
return result.rows.map(mapMaterialDetails); return result.rows.map(mapMaterialDetails);
}; };
const listKnownSuppliers = async () => {
const result = await pool.query(`
SELECT DISTINCT supplier
FROM (
SELECT preferred_supplier AS supplier FROM supply_material_details
UNION ALL
SELECT supplier FROM supply_stock_lots
UNION ALL
SELECT supplier FROM supply_receipts
UNION ALL
SELECT supplier FROM supply_fabric_plans
UNION ALL
SELECT supplier FROM supply_purchase_orders
UNION ALL
SELECT supplier FROM production_orders
UNION ALL
SELECT supplier FROM production_yield_supplier_recommendations
) sources
WHERE NULLIF(BTRIM(supplier), '') IS NOT NULL
ORDER BY supplier;
`);
return result.rows.map(row => row.supplier);
};
const listPurchaseOrders = async () => {
const result = await pool.query(`
SELECT
purchase_order.id, purchase_order.number, purchase_order.supplier, purchase_order.status,
purchase_order.expected_date, purchase_order.notes, purchase_order.created_at, purchase_order.updated_at,
COALESCE((SELECT SUM(item.quantity * COALESCE(item.unit_cost, 0)) FROM supply_purchase_order_items item WHERE item.purchase_order_id = purchase_order.id), 0) AS total,
COALESCE((SELECT JSON_AGG(JSON_BUILD_OBJECT('id', item.id, 'material', item.material, 'quantity', item.quantity, 'unit', item.unit, 'unit_cost', item.unit_cost) ORDER BY item.id) FROM supply_purchase_order_items item WHERE item.purchase_order_id = purchase_order.id), '[]'::json) AS items
FROM supply_purchase_orders purchase_order
ORDER BY purchase_order.created_at DESC, purchase_order.id DESC;
`);
return result.rows.map(mapPurchaseOrder);
};
const createPurchaseOrder = async (payload = {}) => {
const supplier = normalizeText(payload.supplier);
const expectedDate = /^\d{4}-\d{2}-\d{2}$/.test(normalizeText(payload.expectedDate)) ? normalizeText(payload.expectedDate) : null;
const notes = normalizeText(payload.notes);
const items = (Array.isArray(payload.items) ? payload.items : []).map(item => ({
material: normalizeText(item?.material),
quantity: normalizeNumber(item?.quantity),
unit: normalizeText(item?.unit) || 'kg',
unitCost: normalizeNonNegativeNumber(item?.unitCost)
})).filter(item => item.material && item.quantity);
if (!supplier) throw createValidationError('Fornecedor é obrigatório para a ordem de compra.');
if (!items.length) throw createValidationError('Inclua pelo menos um material na ordem de compra.');
const client = await pool.connect();
try {
await client.query('BEGIN');
const orderResult = await client.query(`INSERT INTO supply_purchase_orders (supplier, status, expected_date, notes, updated_at) VALUES ($1, 'draft', $2, NULLIF($3, ''), CURRENT_TIMESTAMP) RETURNING id;`, [supplier, expectedDate, notes]);
const id = orderResult.rows[0].id;
await client.query(`UPDATE supply_purchase_orders SET number = $1 WHERE id = $2;`, [`OC-${String(id).padStart(5, '0')}`, id]);
for (const item of items) {
await client.query(`INSERT INTO supply_purchase_order_items (purchase_order_id, material, quantity, unit, unit_cost) VALUES ($1, $2, $3, $4, $5);`, [id, item.material, item.quantity, item.unit, item.unitCost]);
}
await client.query('COMMIT');
return (await listPurchaseOrders()).find(order => order.id === id);
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
};
const updatePurchaseOrderStatus = async (id, status) => {
const normalizedStatus = normalizeText(status).toLowerCase();
if (!PURCHASE_ORDER_STATUSES.has(normalizedStatus)) throw createValidationError('Status de ordem de compra inválido.');
const result = await pool.query(`UPDATE supply_purchase_orders SET status = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2 RETURNING id;`, [normalizedStatus, id]);
if (!result.rowCount) {
const error = new Error('Ordem de compra não encontrada.');
error.statusCode = 404;
throw error;
}
return (await listPurchaseOrders()).find(order => order.id === Number(id));
};
const saveMaterialDetails = async (materialKey, payload) => { const saveMaterialDetails = async (materialKey, payload) => {
const normalizedKey = normalizeKey(materialKey); const normalizedKey = normalizeKey(materialKey);
if (!normalizedKey) throw createValidationError('Material inválido para edição.'); if (!normalizedKey) throw createValidationError('Material inválido para edição.');
@@ -213,7 +363,7 @@ const buildPurchaseNeeds = (plans, lots, receipts) => {
lots.forEach(lot => { lots.forEach(lot => {
if (lot.unit !== 'kg') return; if (lot.unit !== 'kg') return;
const need = needsByMaterial.get(normalizeKey(lot.product)); const need = needsByMaterial.get(normalizeKey(lot.product));
if (need) need.stockKg += lot.quantity; if (need) need.stockKg += Number(lot.availableQuantity ?? lot.quantity);
}); });
receipts.forEach(receipt => { receipts.forEach(receipt => {
@@ -285,9 +435,10 @@ const listProjectDemandRows = async () => {
COALESCE(period_orders.produto_id, stock.produto_id) AS product_id, COALESCE(period_orders.produto_id, stock.produto_id) AS product_id,
COALESCE(NULLIF(period_orders.product_name, ''), NULLIF(stock.nome, ''), 'Produto sem nome') AS product_name, COALESCE(NULLIF(period_orders.product_name, ''), NULLIF(stock.nome, ''), 'Produto sem nome') AS product_name,
COALESCE(period_orders.quantity_sold, 0)::numeric AS quantity_sold, COALESCE(period_orders.quantity_sold, 0)::numeric AS quantity_sold,
COALESCE(stock.saldo, 0)::numeric AS stock_quantity COALESCE(graphs.balance, stock.saldo, 0)::numeric AS stock_quantity
FROM period_orders FROM period_orders
FULL OUTER JOIN stock ON stock.produto_id = period_orders.produto_id FULL OUTER JOIN stock ON stock.produto_id = period_orders.produto_id
LEFT JOIN stock_graphs_balances graphs ON graphs.produto_id = stock.produto_id
WHERE COALESCE(period_orders.produto_id, stock.produto_id, '') <> '' WHERE COALESCE(period_orders.produto_id, stock.produto_id, '') <> ''
ORDER BY quantity_sold DESC, product_name; ORDER BY quantity_sold DESC, product_name;
`, [DEFAULT_SUPPLY_LOOKBACK_DAYS]); `, [DEFAULT_SUPPLY_LOOKBACK_DAYS]);
@@ -335,7 +486,20 @@ const listCompositionRows = async () => {
FROM product_compositions composition FROM product_compositions composition
JOIN product_composition_components component JOIN product_composition_components component
ON component.product_composition_id = composition.id ON component.product_composition_id = composition.id
WHERE composition.source = 'tiny_olist_v3' WHERE composition.source IN ('tiny_olist_v3', 'manual')
AND NOT EXISTS (
SELECT 1
FROM product_compositions manual_composition
WHERE manual_composition.source = 'manual'
AND composition.source = 'tiny_olist_v3'
AND (
manual_composition.finished_tiny_product_id = composition.finished_tiny_product_id
OR (
COALESCE(manual_composition.finished_tiny_product_id, '') = ''
AND manual_composition.finished_product_sku = composition.finished_product_sku
)
)
)
ORDER BY composition.id, component.id; ORDER BY composition.id, component.id;
`); `);
return result.rows; return result.rows;
@@ -343,9 +507,15 @@ const listCompositionRows = async () => {
const listTinyStockRows = async () => { const listTinyStockRows = async () => {
const result = await pool.query(` const result = await pool.query(`
SELECT produto_id, nome, COALESCE(saldo, 0)::numeric AS saldo SELECT
stock.produto_id,
stock.nome,
COALESCE(stock.saldo, 0)::numeric AS tiny_saldo,
graphs.balance::numeric AS graphs_balance,
COALESCE(graphs.balance, stock.saldo, 0)::numeric AS saldo
FROM stock FROM stock
WHERE COALESCE(produto_id, '') <> '' OR COALESCE(nome, '') <> ''; LEFT JOIN stock_graphs_balances graphs ON graphs.produto_id = stock.produto_id
WHERE COALESCE(stock.produto_id, '') <> '' OR COALESCE(stock.nome, '') <> '';
`); `);
return result.rows; return result.rows;
}; };
@@ -584,7 +754,7 @@ const buildProjectPurchaseNeeds = async (lots, receipts) => {
lots.forEach(lot => { lots.forEach(lot => {
const need = getNeedForInventory(lot.unit, '', lot.product) const need = getNeedForInventory(lot.unit, '', lot.product)
|| needsByMaterial.get(`${normalizeUnit(lot.unit)}:${normalizeKey(lot.product)}`); || needsByMaterial.get(`${normalizeUnit(lot.unit)}:${normalizeKey(lot.product)}`);
if (need) need.stockKg += lot.quantity; if (need) need.stockKg += Number(lot.availableQuantity ?? lot.quantity);
}); });
receipts.forEach(receipt => { receipts.forEach(receipt => {
@@ -766,13 +936,16 @@ const createReceipt = async (payload) => {
if (!product) throw createValidationError('Produto ou material é obrigatório.'); if (!product) throw createValidationError('Produto ou material é obrigatório.');
if (!quantity) throw createValidationError('Quantidade deve ser maior que zero.'); if (!quantity) throw createValidationError('Quantidade deve ser maior que zero.');
const result = await pool.query(` const client = await pool.connect();
try {
await client.query('BEGIN');
const result = await client.query(`
INSERT INTO supply_receipts ( INSERT INTO supply_receipts (
category, product, quantity, unit, supplier, invoice, notes, status, updated_at category, product, quantity, unit, supplier, invoice, notes, status, updated_at
) )
VALUES ($1, $2, $3, $4, $5, $6, $7, 'pending', CURRENT_TIMESTAMP) VALUES ($1, $2, $3, $4, $5, $6, $7, 'pending', CURRENT_TIMESTAMP)
RETURNING id, category, product, quantity, unit, supplier, invoice, notes, status, created_at, updated_at, approved_at RETURNING id, category, product, quantity, unit, supplier, invoice, notes, status, created_at, updated_at, approved_at
`, [ `, [
category, category,
product, product,
quantity, quantity,
@@ -780,9 +953,31 @@ const createReceipt = async (payload) => {
normalizeText(payload.supplier) || null, normalizeText(payload.supplier) || null,
normalizeText(payload.invoice) || null, normalizeText(payload.invoice) || null,
normalizeText(payload.notes) || null normalizeText(payload.notes) || null
]); ]);
const receipt = result.rows[0];
return mapReceipt(result.rows[0]); const orderResult = await client.query(`
INSERT INTO supply_purchase_orders (supplier, status, notes, updated_at)
VALUES ($1, 'pending_financial', $2, CURRENT_TIMESTAMP)
RETURNING id;
`, [
normalizeText(payload.supplier) || 'Sem fornecedor',
`Gerada automaticamente pelo recebimento #${receipt.id}${receipt.invoice ? ` · NF ${receipt.invoice}` : ''}`
]);
const purchaseOrderId = orderResult.rows[0].id;
await client.query(`UPDATE supply_purchase_orders SET number = $1 WHERE id = $2;`, [`OC-${String(purchaseOrderId).padStart(5, '0')}`, purchaseOrderId]);
await client.query(`INSERT INTO supply_purchase_order_items (purchase_order_id, material, quantity, unit) VALUES ($1, $2, $3, $4);`, [purchaseOrderId, receipt.product, receipt.quantity, receipt.unit]);
const linkedReceipt = await client.query(`
UPDATE supply_receipts SET purchase_order_id = $2, updated_at = CURRENT_TIMESTAMP WHERE id = $1
RETURNING id, category, product, quantity, unit, supplier, invoice, notes, status, purchase_order_id, created_at, updated_at, approved_at;
`, [receipt.id, purchaseOrderId]);
await client.query('COMMIT');
return mapReceipt({ ...linkedReceipt.rows[0], purchase_order_number: `OC-${String(purchaseOrderId).padStart(5, '0')}`, purchase_order_status: 'pending_financial' });
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
}; };
const approveReceipt = async (id) => { const approveReceipt = async (id) => {
@@ -792,10 +987,13 @@ const approveReceipt = async (id) => {
await client.query('BEGIN'); await client.query('BEGIN');
const receiptResult = await client.query(` const receiptResult = await client.query(`
SELECT id, category, product, quantity, unit, supplier, invoice, notes, status, created_at, updated_at, approved_at SELECT receipt.id, receipt.category, receipt.product, receipt.quantity, receipt.unit, receipt.supplier, receipt.invoice,
FROM supply_receipts receipt.notes, receipt.status, receipt.purchase_order_id, receipt.created_at, receipt.updated_at, receipt.approved_at,
WHERE id = $1 purchase_order.number AS purchase_order_number, purchase_order.status AS purchase_order_status
FOR UPDATE FROM supply_receipts receipt
LEFT JOIN supply_purchase_orders purchase_order ON purchase_order.id = receipt.purchase_order_id
WHERE receipt.id = $1
FOR UPDATE OF receipt
`, [id]); `, [id]);
if (!receiptResult.rowCount) { if (!receiptResult.rowCount) {
@@ -808,13 +1006,17 @@ const approveReceipt = async (id) => {
return mapReceipt(receipt); return mapReceipt(receipt);
} }
if (receipt.purchase_order_id && !['approved', 'received'].includes(receipt.purchase_order_status)) {
throw createValidationError(`A ${receipt.purchase_order_number || 'OC vinculada'} ainda aguarda aprovação financeira.`);
}
const updatedReceiptResult = await client.query(` const updatedReceiptResult = await client.query(`
UPDATE supply_receipts UPDATE supply_receipts
SET status = 'approved', SET status = 'approved',
approved_at = CURRENT_TIMESTAMP, approved_at = CURRENT_TIMESTAMP,
updated_at = CURRENT_TIMESTAMP updated_at = CURRENT_TIMESTAMP
WHERE id = $1 WHERE id = $1
RETURNING id, category, product, quantity, unit, supplier, invoice, notes, status, created_at, updated_at, approved_at RETURNING id, category, product, quantity, unit, supplier, invoice, notes, status, purchase_order_id, created_at, updated_at, approved_at
`, [id]); `, [id]);
const lotResult = await client.query(` const lotResult = await client.query(`
@@ -848,8 +1050,12 @@ const approveReceipt = async (id) => {
`Recebimento aprovado${receipt.invoice ? ` · NF ${receipt.invoice}` : ''}` `Recebimento aprovado${receipt.invoice ? ` · NF ${receipt.invoice}` : ''}`
]); ]);
if (receipt.purchase_order_id) {
await client.query(`UPDATE supply_purchase_orders SET status = 'received', updated_at = CURRENT_TIMESTAMP WHERE id = $1 AND status = 'approved';`, [receipt.purchase_order_id]);
}
await client.query('COMMIT'); await client.query('COMMIT');
return mapReceipt(updatedReceiptResult.rows[0]); return mapReceipt({ ...updatedReceiptResult.rows[0], purchase_order_number: receipt.purchase_order_number, purchase_order_status: receipt.purchase_order_id ? 'received' : '' });
} catch (error) { } catch (error) {
await client.query('ROLLBACK'); await client.query('ROLLBACK');
throw error; throw error;
@@ -899,6 +1105,49 @@ const createFabricPlan = async (payload) => {
return mapFabricPlan(result.rows[0]); return mapFabricPlan(result.rows[0]);
}; };
const TRANSFORMATION_STAGES = new Set(['knitting', 'dyeing', 'finishing']);
const TRANSFORMATION_STATUSES = new Set(['planned', 'in_progress', 'completed', 'canceled']);
const createMaterialTransformation = async (payload = {}) => {
const stage = normalizeText(payload.stage).toLowerCase();
const inputMaterial = normalizeText(payload.inputMaterial);
const outputMaterial = normalizeText(payload.outputMaterial);
const inputQuantityKg = normalizeNumber(payload.inputQuantityKg);
const expectedLossPercent = normalizeNonNegativeNumber(payload.expectedLossPercent) ?? 0;
if (!TRANSFORMATION_STAGES.has(stage)) throw createValidationError('Etapa de transformação inválida.');
if (!inputMaterial || !outputMaterial) throw createValidationError('Material de entrada e saída são obrigatórios.');
if (!inputQuantityKg) throw createValidationError('Quantidade de entrada deve ser maior que zero.');
if (expectedLossPercent >= 100) throw createValidationError('A perda esperada deve ser menor que 100%.');
const expectedOutputKg = Number((inputQuantityKg * (1 - (expectedLossPercent / 100))).toFixed(4));
const result = await pool.query(`
INSERT INTO supply_material_transformations (
batch_code, stage, input_material, input_quantity_kg, output_material, output_color,
expected_loss_percent, expected_output_kg, supplier, priority, status, notes, updated_at
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, 'planned', $11, CURRENT_TIMESTAMP)
RETURNING id, batch_code, stage, input_material, input_quantity_kg, output_material, output_color,
expected_loss_percent, expected_output_kg, supplier, priority, status, notes, created_at, updated_at
`, [
normalizeText(payload.batchCode) || null, stage, inputMaterial, inputQuantityKg, outputMaterial,
normalizeText(payload.outputColor) || null, expectedLossPercent, expectedOutputKg,
normalizeText(payload.supplier) || null, normalizeText(payload.priority) || 'Normal', normalizeText(payload.notes) || null
]);
return mapMaterialTransformation(result.rows[0]);
};
const updateMaterialTransformationStatus = async (id, status) => {
const normalizedStatus = normalizeText(status).toLowerCase();
if (!TRANSFORMATION_STATUSES.has(normalizedStatus)) throw createValidationError('Status da transformação inválido.');
const result = await pool.query(`
UPDATE supply_material_transformations
SET status = $2, updated_at = CURRENT_TIMESTAMP
WHERE id = $1
RETURNING id, batch_code, stage, input_material, input_quantity_kg, output_material, output_color,
expected_loss_percent, expected_output_kg, supplier, priority, status, notes, created_at, updated_at
`, [id, normalizedStatus]);
if (!result.rows[0]) throw createValidationError('Transformação não encontrada.');
return mapMaterialTransformation(result.rows[0]);
};
const deleteFabricPlan = async (id) => { const deleteFabricPlan = async (id) => {
await pool.query(` await pool.query(`
UPDATE supply_fabric_plans UPDATE supply_fabric_plans
@@ -922,6 +1171,19 @@ const updateLotQuantity = async (client, lotId, quantity) => {
return mapLot(result.rows[0]); return mapLot(result.rows[0]);
}; };
const getActiveReservationQuantity = async (client, lotId) => {
const result = await client.query(`
SELECT quantity, consumed_quantity
FROM cut_plan_lot_reservations
WHERE supply_lot_id = $1
AND status = 'active'
FOR UPDATE;
`, [lotId]);
return result.rows.reduce((total, reservation) => (
total + Number(reservation.quantity || 0) - Number(reservation.consumed_quantity || 0)
), 0);
};
const adjustInventoryLot = async (id, payload) => { const adjustInventoryLot = async (id, payload) => {
const countedQuantity = normalizeNonNegativeNumber(payload.countedQuantity); const countedQuantity = normalizeNonNegativeNumber(payload.countedQuantity);
const reason = normalizeText(payload.reason); const reason = normalizeText(payload.reason);
@@ -945,6 +1207,10 @@ const adjustInventoryLot = async (id, payload) => {
const lot = lotResult.rows[0]; const lot = lotResult.rows[0];
const currentQuantity = Number(lot.quantity); const currentQuantity = Number(lot.quantity);
const reservedQuantity = await getActiveReservationQuantity(client, lot.id);
if (countedQuantity < reservedQuantity) {
throw createValidationError(`A contagem não pode ficar abaixo de ${reservedQuantity} ${lot.unit} já reservados para corte.`);
}
const difference = countedQuantity - currentQuantity; const difference = countedQuantity - currentQuantity;
const updatedLot = await updateLotQuantity(client, lot.id, countedQuantity); const updatedLot = await updateLotQuantity(client, lot.id, countedQuantity);
@@ -999,6 +1265,35 @@ const consumeLotForProduction = async (id, payload) => {
if (lot.status !== 'active' || currentQuantity <= 0) throw createValidationError('Lote sem saldo disponível.'); if (lot.status !== 'active' || currentQuantity <= 0) throw createValidationError('Lote sem saldo disponível.');
if (quantity > currentQuantity) throw createValidationError('Quantidade de saída maior que o saldo do lote.'); if (quantity > currentQuantity) throw createValidationError('Quantidade de saída maior que o saldo do lote.');
let reference = '';
if (productionOrderNumber) {
const orderResult = await client.query(`
SELECT order_reference
FROM production_orders
WHERE number = $1
LIMIT 1;
`, [productionOrderNumber]);
reference = normalizeText(orderResult.rows[0]?.order_reference);
}
const allReservationsResult = await client.query(`
SELECT id, cut_plan_reference, quantity, consumed_quantity
FROM cut_plan_lot_reservations
WHERE supply_lot_id = $1
AND status = 'active'
ORDER BY created_at, id
FOR UPDATE;
`, [lot.id]);
const totalReserved = allReservationsResult.rows.reduce((total, reservation) => (
total + Number(reservation.quantity) - Number(reservation.consumed_quantity)
), 0);
const reservedByThisPlan = allReservationsResult.rows
.filter(reservation => reference && reservation.cut_plan_reference === reference)
.reduce((total, reservation) => total + Number(reservation.quantity) - Number(reservation.consumed_quantity), 0);
const permittedQuantity = Math.max(0, currentQuantity - totalReserved + reservedByThisPlan);
if (quantity > permittedQuantity) {
throw createValidationError(`O lote possui ${Math.max(0, currentQuantity - totalReserved)} ${lot.unit} livres e ${reservedByThisPlan} ${lot.unit} reservados para esta OP.`);
}
const updatedLot = await updateLotQuantity(client, lot.id, currentQuantity - quantity); const updatedLot = await updateLotQuantity(client, lot.id, currentQuantity - quantity);
const movementReason = [ const movementReason = [
productionOrderNumber ? `OP ${productionOrderNumber}` : '', productionOrderNumber ? `OP ${productionOrderNumber}` : '',
@@ -1019,6 +1314,36 @@ const consumeLotForProduction = async (id, payload) => {
movementReason || 'Saída para produção' movementReason || 'Saída para produção'
]); ]);
if (productionOrderNumber) {
if (reference) {
const reservationsResult = await client.query(`
SELECT id, quantity, consumed_quantity
FROM cut_plan_lot_reservations
WHERE supply_lot_id = $1
AND cut_plan_reference = $2
AND status = 'active'
ORDER BY created_at, id
FOR UPDATE;
`, [lot.id, reference]);
let remainingToApply = quantity;
for (const reservation of reservationsResult.rows) {
if (remainingToApply <= 0) break;
const reservedRemaining = Number(reservation.quantity) - Number(reservation.consumed_quantity);
const consumed = Math.min(reservedRemaining, remainingToApply);
if (consumed <= 0) continue;
const nextConsumedQuantity = Number(reservation.consumed_quantity) + consumed;
await client.query(`
UPDATE cut_plan_lot_reservations
SET consumed_quantity = $1,
status = CASE WHEN $1 >= quantity THEN 'consumed' ELSE 'active' END,
updated_at = CURRENT_TIMESTAMP
WHERE id = $2;
`, [nextConsumedQuantity, reservation.id]);
remainingToApply -= consumed;
}
}
}
await client.query('COMMIT'); await client.query('COMMIT');
return updatedLot; return updatedLot;
} catch (error) { } catch (error) {
@@ -1034,6 +1359,8 @@ module.exports = {
approveReceipt, approveReceipt,
consumeLotForProduction, consumeLotForProduction,
createFabricPlan, createFabricPlan,
createMaterialTransformation,
createPurchaseOrder,
createReceipt, createReceipt,
deleteFabricPlan, deleteFabricPlan,
deleteReceipt, deleteReceipt,
@@ -1041,10 +1368,15 @@ module.exports = {
buildProjectPurchaseNeeds, buildProjectPurchaseNeeds,
buildPurchaseNeeds, buildPurchaseNeeds,
listFabricPlans, listFabricPlans,
listMaterialTransformations,
listMaterialDetails, listMaterialDetails,
listKnownSuppliers,
listPurchaseOrders,
listLots, listLots,
listMovements, listMovements,
listPurchaseNeeds, listPurchaseNeeds,
listReceipts, listReceipts,
saveMaterialDetails saveMaterialDetails,
updateMaterialTransformationStatus,
updatePurchaseOrderStatus
}; };

View File

@@ -1,6 +1,8 @@
const crypto = require('crypto'); const crypto = require('crypto');
const { pool } = require('../db'); const { pool } = require('../db');
const SYSTEM_OPERATIONAL_USER_NAME = 'Super Admin';
const HASH_ALGORITHM = 'scrypt'; const HASH_ALGORITHM = 'scrypt';
const KEY_LENGTH = 64; const KEY_LENGTH = 64;
@@ -59,6 +61,38 @@ const listUsers = async () => {
return result.rows.map(publicUserFields); return result.rows.map(publicUserFields);
}; };
// This deliberately exposes only the identity needed to assign operational
// responsibility. It is available to every signed-in Graphs user.
const listActiveOperationalUsers = async () => {
const result = await pool.query(
`SELECT id, name
FROM app_users
WHERE is_active = TRUE
ORDER BY LOWER(name), id`
);
return [
{ id: null, name: SYSTEM_OPERATIONAL_USER_NAME, isSystem: true },
...result.rows.map((row) => ({
id: Number(row.id),
name: row.name,
isSystem: false
}))
];
};
const getActiveOperationalUserById = async (id, client = pool) => {
const result = await client.query(
`SELECT id, name
FROM app_users
WHERE id = $1 AND is_active = TRUE
LIMIT 1`,
[id]
);
return result.rows[0] || null;
};
const createUser = async ({ name, email, password }) => { const createUser = async ({ name, email, password }) => {
const normalizedName = String(name || '').trim(); const normalizedName = String(name || '').trim();
const normalizedEmail = normalizeEmail(email); const normalizedEmail = normalizeEmail(email);
@@ -200,9 +234,12 @@ module.exports = {
findUserById, findUserById,
findUserByEmail, findUserByEmail,
generatePassword, generatePassword,
getActiveOperationalUserById,
listActiveOperationalUsers,
listUsers, listUsers,
normalizeEmail, normalizeEmail,
publicUserFields, publicUserFields,
SYSTEM_OPERATIONAL_USER_NAME,
updateUser, updateUser,
verifyPassword verifyPassword
}; };

View File

@@ -11,6 +11,7 @@ const {
test('normalizeFamilyKey accepts only known cut families', () => { test('normalizeFamilyKey accepts only known cut families', () => {
assert.equal(normalizeFamilyKey('blcs'), 'BLCS'); assert.equal(normalizeFamilyKey('blcs'), 'BLCS');
assert.equal(normalizeFamilyKey(' BLOS '), 'BLOS'); assert.equal(normalizeFamilyKey(' BLOS '), 'BLOS');
assert.equal(normalizeFamilyKey('blrm'), 'BLRM');
assert.equal(normalizeFamilyKey('OUTROS'), ''); assert.equal(normalizeFamilyKey('OUTROS'), '');
assert.equal(normalizeFamilyKey('unknown'), ''); assert.equal(normalizeFamilyKey('unknown'), '');
}); });
@@ -21,10 +22,12 @@ test('normalizeFamilyYields keeps positive numeric yield rules', () => {
BLOS: 0, BLOS: 0,
BLMC: -1, BLMC: -1,
BLPM: '12.5', BLPM: '12.5',
BLRM: '17',
OUTROS: 99 OUTROS: 99
}), { }), {
BLCS: 50, BLCS: 50,
BLPM: 12.5 BLPM: 12.5,
BLRM: 17
}); });
}); });

View File

@@ -208,7 +208,7 @@ const withFakeDatabase = async (run) => {
if (normalizedSql.includes('FROM product_compositions composition')) { if (normalizedSql.includes('FROM product_compositions composition')) {
const compositions = state.compositions.filter(item => ( const compositions = state.compositions.filter(item => (
item.source === params[0] item.source === params[0]
&& (params[1] === '' || item.finished_tiny_product_id === params[1] || item.finished_product_sku === String(params[1]).toUpperCase()) && (params[1] === '' || item.finished_tiny_product_id === params[1] || item.external_source_id === params[1] || item.finished_product_sku === String(params[1]).toUpperCase())
)); ));
return { return {
rows: compositions.map(composition => ({ rows: compositions.map(composition => ({
@@ -342,6 +342,36 @@ test('product composition detail lookup returns stored components', async () =>
}); });
}); });
test('product composition detail lookup supports an exported composition linked by external product ID', async () => {
await withFakeDatabase(async () => {
const response = await invokeHandler(tinyCompositionImportHandler, {
body: {
compositions: [{
externalSourceId: '919483307',
finishedProductSku: 'BLCS.PTO.G',
finishedProductDescription: 'BASE LISA CAMISETA COR PRETO TAMANHO - G',
finishedProductUnit: 'UN',
finishedTinyProductId: '',
components: [{
componentSku: 'ETIQ.T.G',
componentName: 'ETIQUETA DE TAMANHO G',
quantityPerUnit: 1,
unit: 'UN'
}]
}]
}
});
assert.equal(response.statusCode, 201);
const detail = await invokeHandler(productCompositionHandler, { params: { productId: '919483307' } });
assert.equal(detail.statusCode, 200);
assert.equal(detail.body.composition.finishedProductSku, 'BLCS.PTO.G');
assert.equal(detail.body.composition.finishedTinyProductId, '919483307');
assert.equal(detail.body.composition.components.length, 1);
});
});
test('product composition export endpoint returns every stored composition', async () => { test('product composition export endpoint returns every stored composition', async () => {
await withFakeDatabase(async () => { await withFakeDatabase(async () => {
await invokeHandler(tinySyncHandler, { body: compositionPayload() }); await invokeHandler(tinySyncHandler, { body: compositionPayload() });

View File

@@ -16,6 +16,27 @@ test('extractObservationFields reads Brazilian production notes without using th
}); });
}); });
test('extractObservationFields supports the validated Olist bobbin multiplier, lot, kg and ribana formats', () => {
const result = extractObservationFields('FORNECEDOR: ALTUS\nBOBINAS: 07(X2) - 287KG\nRENDIMENTO: 5,55 - 494KG\n- Lote 01\nRIBANA: 3,59');
assert.deepEqual(result, {
supplier: 'ALTUS',
lotCode: '01',
yieldPiecesPerKg: 5.55,
rollQuantity: 14,
fabricKg: 287,
ribKg: 3.59
});
});
test('extractObservationFields reads standalone KG lines for fabric weight', () => {
const result = extractObservationFields('FORNECEDOR: GOL\nBOBINAS: 06\nKG : 204,38\nRENDIMENTO: 5,0\nLote 02');
assert.equal(result.rollQuantity, 6);
assert.equal(result.fabricKg, 204.38);
assert.equal(result.lotCode, '02');
});
test('parseFinalizedProductionOrderCsv supports multiline observations and skips non-finalized OPs', () => { test('parseFinalizedProductionOrderCsv supports multiline observations and skips non-finalized OPs', () => {
const csv = `id_op,numero_op,sku_produto,descricao_produto,quantidade_produzida,data_emissao,data_conclusao,situacao,observacoes,pedido_relacionado\n1,10,BLCS.PTO.G,Camiseta preta G,100,2026-08-01T00:00:00.000Z,2026-08-02T00:00:00.000Z,Finalizada,"FORNECEDOR: NEXSTAR\nRENDIMENTO: 5,44\nQUANTIDADE DE ROLOS: 30\nQUILOS DE MALHA: 577,90",PLANO-1\n2,11,BLCS.PTO.M,Camiseta preta M,100,2026-08-01,2026-08-02,Em andamento,Teste,`; const csv = `id_op,numero_op,sku_produto,descricao_produto,quantidade_produzida,data_emissao,data_conclusao,situacao,observacoes,pedido_relacionado\n1,10,BLCS.PTO.G,Camiseta preta G,100,2026-08-01T00:00:00.000Z,2026-08-02T00:00:00.000Z,Finalizada,"FORNECEDOR: NEXSTAR\nRENDIMENTO: 5,44\nQUANTIDADE DE ROLOS: 30\nQUILOS DE MALHA: 577,90",PLANO-1\n2,11,BLCS.PTO.M,Camiseta preta M,100,2026-08-01,2026-08-02,Em andamento,Teste,`;
const result = parseFinalizedProductionOrderCsv(csv); const result = parseFinalizedProductionOrderCsv(csv);
@@ -28,3 +49,16 @@ test('parseFinalizedProductionOrderCsv supports multiline observations and skips
assert.equal(result.records[0].fabricKg, 577.9); assert.equal(result.records[0].fabricKg, 577.9);
assert.equal(result.records[0].yieldPiecesPerKg, 5.44); assert.equal(result.records[0].yieldPiecesPerKg, 5.44);
}); });
test('parseFinalizedProductionOrderCsv accepts structured production fields from a completed template', () => {
const csv = 'id_op,numero_op,sku_produto,descricao_produto,quantidade_produzida,data_emissao,data_conclusao,situacao,observacoes,fornecedor,lote,quantidade_rolos,quilos_malha,quilos_ribana,rendimento\n1,10,BLCS.PTO.G,Camiseta preta G,100,2026-08-01,2026-08-02,Finalizada,,TECEBEM,0245163,30,"577,90","28,89","5,44"';
const result = parseFinalizedProductionOrderCsv(csv);
assert.equal(result.records.length, 1);
assert.equal(result.records[0].supplier, 'TECEBEM');
assert.equal(result.records[0].lotCode, '0245163');
assert.equal(result.records[0].rollQuantity, 30);
assert.equal(result.records[0].fabricKg, 577.9);
assert.equal(result.records[0].ribKg, 28.89);
assert.equal(result.records[0].yieldPiecesPerKg, 5.44);
});

View File

@@ -0,0 +1,175 @@
%PDF-1.4
%“Œ‹ž ReportLab Generated PDF document (opensource)
1 0 obj
<<
/F1 2 0 R /F2 3 0 R /F3 4 0 R
>>
endobj
2 0 obj
<<
/BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font
>>
endobj
3 0 obj
<<
/BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding /Name /F2 /Subtype /Type1 /Type /Font
>>
endobj
4 0 obj
<<
/BaseFont /Symbol /Name /F3 /Subtype /Type1 /Type /Font
>>
endobj
5 0 obj
<<
/Contents 14 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 13 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
6 0 obj
<<
/Contents 15 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 13 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
7 0 obj
<<
/Contents 16 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 13 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
8 0 obj
<<
/Contents 17 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 13 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
9 0 obj
<<
/Contents 18 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 13 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
10 0 obj
<<
/Contents 19 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 13 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
11 0 obj
<<
/PageMode /UseNone /Pages 13 0 R /Type /Catalog
>>
endobj
12 0 obj
<<
/Author (\(anonymous\)) /CreationDate (D:20260827153832-03'00') /Creator (\(unspecified\)) /Keywords () /ModDate (D:20260827153832-03'00') /Producer (ReportLab PDF Library - \(opensource\))
/Subject (\(unspecified\)) /Title (\(anonymous\)) /Trapped /False
>>
endobj
13 0 obj
<<
/Count 6 /Kids [ 5 0 R 6 0 R 7 0 R 8 0 R 9 0 R 10 0 R ] /Type /Pages
>>
endobj
14 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1327
>>
stream
Gatm:9iL(3&A@7.oJ&E26B^F@eXkDr?tYB9?ma[=.(0a^fp7ce=ap@Y'ZY2+W1@@r&7Whb<qgsE]?PlcLP>bR%IhC>c7Ttg%Z;&U5m/aAiF4NgT!?+080Xgn1.Z[Y,\I_WS)$dI1/9eD.>KlW8BbW0%A%3c9$1ZGKQ/Hf5@I-Ln.b58f(aY;-%`E*6psV4,RdGg-*(JIBh!`)1uH;D6]Cpk6;@PWc8(^-c-a%PNHn"'TnYBJJkMQl_P.%%Gpd6k*4Q8L_oJogIWb\P*,]XI^7BcQEqAu%qDVM=!A$FNBq1;Ro$.10?i:TE%H%'6QF1Tkj.J,GAQAWk_A9lgiCjioiCj[UD@\Z7Rmi!AN"(;K0pNVa+p_*U7'\6&nGp?W+!5Jc,q\15s(lEP\S5;UFijd*ASbMJ6G!nE/(Ni2>g88qr.8*%fBHOEW)]a4WChB?%=>6bFP_?d`YQ.*50qfKU47teMVlL*#*k[h<Qual4nkr`#Ec-D1"3aeN(5>90dDp[nk_q?"3sC=7##t50pL#hP-(MiTnt_Ln:8UfGSH7M=[))Qr*bW&4$$APaZE7/)2Yhe8:Tk3.@bd:+N(cD($M!m.WYca.?jr4lZD5AEp_XL'ZWF[l(HRlJR`8^J:ZGL%/b`_'ajfk].@5h=sP?ee-CIuO"@">FPa"Nla!ifGkHmi9-fUm1]X)6<a.GEHHb5CI1&M9]Rr_K>,acX-8a!FY6atFl9"N4ltSW;raK0nP5%Hu"Q<Po8.l*@Of-.@!*3,"Ji/.Q>#i8(/p^5V8cW5+-TG(\k72:7[0jduB:%"snH4i9iR+>@\A\e5)])Vms3&=IIP&=Pe]l__p3ho5RVrg-j]Vdp3U4+0U&,u1NE[9)&*cesm81f4,6?D+iA=lRLn<Jmp6n2F`pXSh`*&U$+L."BkDVY`7L[":bt&3Am)3#pj;@;8p`:L)knCY;9W+0iS(CoYg$BoNN+MUl6'=t.l6fRJO^5TZ"$t%H'_Dl^Zh=iAB^p!kIM)qbO%9G&8#5.Hnp"PV$J-3n@q^%^25!cH0"9q;T\H($4!q<AlNa8[Rf\A3lP1nn[5dj/Z5gdrg0.gO7TFC$Zs?0!j"pO1[g(Tt/(`EaLTXQbb\O6dj88AH`8D,G;.882aHk_`f=giRN32ti;P>2)auu'JCGTZ\EtJ+!&/8;_Tmk$2KR,Ean%b-E[2lrF33:X%jT2hD/+_Q^'!G9dA&cRRO_F?tLi7s\T(,mX1mX?9&SA[$aY5Y#=c$gBKLq=5*f$.;^NUV8/2D0*cnZ1@KZ:@AnF3Pm'&hn"I,?5a_NXqnDZ,r.,Kmmr&qelPC'!0q~>endstream
endobj
15 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 2210
>>
stream
Gb!;e?$G$]&q0MXQq)"Sbn/fgV!M%FPBKa2HgK>+>@&EZ;NR%E"-BZ]If8+(-'E:Zf?%nD-T\LX&d2f!B'u4@"N>VBn@,?PIU(9YT*,HlX"B/b8nmrK]j@7X3"u1OkqZEho2s@%:Em>?1-C?HObgt3(DI1<b[lNQ\P+`;gRVbJ0eAO9WkoC3`.F>;#qmu:ku)f\=Sc+.VN%IBY&U>eF_gq?B_@rt5Hka7iH<9S3WB.J`$3pi,&#(?g#$2]V:m>4@1fJ>f<@_A="2S"C@Kh6\p0]Tg"$ieU8q[ArAI4\9>4X-f!M!a'=Q:`iUT+dH:b]t<*A*>gnG=MVT.l;GXYq#G[-VqJc#<%mB"gH4L4Q!-4nI)_L((-<ESEl:%T-5i[>b>RF@cFo?1:D8/%X`d3?UqIU=jDo_ZsIFdSo?'Np[?,0-%5DB><TLgg;IJ;&qmSB`GBMem-YO<Vl7'P6MaQ5$0:2MAPoQ^'SQ7?jTnRaVGS3)/-L0I=L1h$]noLTQ`3"Qk=,BYVjbR;p92+nYU[6KgkK8>KMKp1ct$=<'$.Ll^uj+caHNR!;joanI.FT,3!P2.@2F:Pf&.mVTsoFg_iY4CC<[^sIru"ZC'-o8X3ikeHil%mKq,^ppXO*[(I3(\Zn@i).S^.*3:96u8\SmkWbKH(6Ik&C#[5Xa1j)is!?i)85NFe!*<dSFJ9UbG4/*"%7meCkI\0_$YiWg[X3Nd-N5mi`:Jt(t1],6b:)W71H0PcI@_FD$m&\5-kW,CK3jrT0B)+cFlsCjN`9(`j@t.$5DKY17fEG3-2.?.6"s.FYtGX=!a%1?!Vg*($RecFMhW"V`Sj[.3/B&iGrW_VB4hoi::Ul5ACQcQj&$Ce+Ue3$ZF7L.B?:JmJ67RPf`SI>]:o6gCXK,1"]S5K#KPfgS%e`JX,tqojC$s>BY3ud/g[s-G``T;cB\76T&#UdC"H<V"@XM7emi#F:FEoFA)kuO(.nbe*C5eW\=XGnY%auVHVg@?.\u.#/N+PQ4Oun?bPndI2j-jbX^daR:X5/:o^rk\=IU,(]8d_(nnj^5:7FIN@`F`@!3V_s)9JP\f;e$SI@P>fmS;f`$1\3r,eCCdVM6XcX/6a2\q>;&1\^-d@Nee[;RVumcf=c!^ZX4@#-pK9EcYgs*VY5IsCDM<f@h`LPi$J0:32`;stc;JUA?WY9P2lDPq7116U@/\i.p"WI7k$5jqnZ%X8:VR1KiqcM-3rTtP+(`#dsuiNZ6H#&88V(3Lm[8@8aSkWTE,i+sH9%>86jF]@C\#K>51+/irA9VD=!i=WbTPSsrYlrq$0F6Xa?.TLEVi?3sd.WG`SXJ*IW1$u#R2en#G=HZ8b#kFW+NE0J^GP:W^i<qsHCLpU.<Vrn@:rIh+YbTneTbu,bcX?nf,2@B!6^Ag\*j:0ON3u$^dn]q,\3\dA,6amK&$/'B8Kl0JU1dlC][ZtVU]a_cW^+)`1eF)DI6^\b&J%K+.RjV(8TDjM*qb96>uHi(/Laq"E=cGsC-Goqj;dRHpFIUZf='!p3%f&r$RXrgZ.:H/IJPa&N)F\(#MYmV@5dK:1mF=cs*bV0E$W4Bd:_(b<Mg3?-_=hV!\oH6n-B6mor'[\YV1*(.!'Tq-,+qdguqWlqgf#9],54qhO;fAD2`CLD:YT>K;B+76@%Gbb&EBdL6%N.UBgX7fE)'Fc?O5&-CF#!FpAW=[O6S^l5K7GPhU9Zp3cLEhoJQ]M2f6I$=&6<H7UdZ2U?iL,"*,%MU]oQA:PT$6B63p5@k/7mol"F7MIiu2%.uZ/hAb"q<Trs--HVR4b$m3T7aA$/@dqO]r+M^O>=tnB8HpT=0s*Sd&0!PcLpk;k1.b"N=&0N%^ffF/d'q7^@]GrTTH^c9jsH.X$'MeWk?k)f-YS^qZAL`#'\kjO*^AUC[clpMKcZ,"-n>Y@8NEcoS!.18BdndRSTg13ZQZDm]2']0b3'BWR89/TlFe;=a0Vcl$mlVH07CXWE6),Z_,#8d_pPSKUNc\h(#SckIaRg^8p#/dJ9eWd[t<+pUPAopm7/ECQ8bFd=SZug)k(_5qV"^X7'6H2f0C3>!BG`1@WbJZ=O">i1n'c(fVuT&+33ld.&,jM%CnOnUQC_j1nNC0p[2B))uB:ZY=HIF:$i9-@(efD`jZ46?j*D(DP.eVnF9Al,R-Fke?sji12Ea@>T7>\N3pZH8lG%8%V*#rrmnQo`P~>endstream
endobj
16 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1991
>>
stream
Gb!SmbAu>q']&X:mS_0Z$t#a)A<hK;S@3TM[4?):$<#e*#oXpdf3dEV61LXN[c=0+S;p()"FpKt\\L3AE'QTnJbtr!7,Q8ReIMZa+jJUQ`*Q./?g7I`dFD*uCDkI2=Z#XNRO""3:89P86a[a*_(5o6$\E%)Nq""4@WZ*<8*DbmL)JH&`t0Bt;][-AG_"1HB#;tgdnfY"<g#*#d\pCdQ+"%:V7NU);2[jW:[jD:il):o?deiu:[K0/rUct\(]*YIUe*Q#RpHqjP8SZ+GijeB`KP!U"['BuO+dmo;_O#l32%#`0Tt!?d3ZOG!0.cNOV6NFA4h]f)e\q>%m&u@2dJDi?4oY73(<Lf73d<;69=Z2C3Bl2f,6X;LQ.JQP`s.ATV"s*7+m:PbcG>`RJ_-$=*;RV/Z.F]`HW$8hP=Yo@tG!sr"oj]Omc!Xbj_G(h9aq#H\c959P5s)[h1?`?QCc.ZFhTR-K,*$p,U_!N$nULL4O%1?<?-o"3(GSjs,1P1@DMI\iA([_q7$e).kX!]\.k("Ci/;A3qs7VW1D+K^K6ZaJZ'WA;H_kXI?%V["s6+OM$ln*u0f7.TV/Hc<ld;6rhuOoHCG9<am@,\.o?>fK-Id?1U`kP"S3'kO?G=Rqe*<aO*V_V8@uYmXpK18,jXs;iRd,<gWunheklF8n*HQb"U8FX04DF;AtZA7Nqp;\P]fP1=?V]=qO#!C+ECJ,^0\l@A_2I7PbU^<P"+S8Cu@c-ACPgWO2f.%r`(%>(L#N)>MOcE>)E!WQj:_g^ChK<Xc7W6m43(1eh1cDQO3iQje?Q[PBoIi>9-[O$&`B#Wi-80hM.]``&]-599jE?\8fWpKP'TaQUK@qnjn5YLq`J3bWW>Z]E)$F-e09`T3]GN2P-Ug,sk5:VO-K^T0fc["4kg^PV<0piL).-\44g6eR+1Rgt_>rI2Db!umhY!boDY*id!DgY`AnLSrPn;ol<-Yk0u`dkk)K.Zb@8SO9GXO_2"$>P(i5m9D^8&@0+7UlF*LS=h$#jH&iU<dGnTK`79NcWYR:I]p7id3VR5n;P'+T[KQ>6qLqZM*+T#Z9DTm%4.AtCe_JbmYV@b70@18%fQ_&`B>''ZI*g)&bKW]F5bNm`E(\bD%Hps;f0`&)M^b]`8cRN1Pp?Yl&g*gEl0r!$%`mB2mk+pRW?U.K\97]aL6QCglu"4nk#)M$"Ip`p!WI`XL0Kf.T`!fj:5(%Xo:Y.E-lu2kH<alb[Jlj%Wgi;gh@7<dd,:h_2?57_0Z+C0_rlRn@RamZ*O[lXdPO<&cN=F1GANhrP@&rR42iWQ*j1J#V/[X.9H6b!1qO_7$cDlJDsYY.&?OSl<q&X1I!928lM^J\nO:EKp%WbK2e10dH69#UiSbWo]SV^IA[hY#ckW^i$d%7$@9$Z)8*n,J\A&ub:uEKg528ldu-G_M7L9)!d8?143XSP<faq,bm?3LiQSn>_l-`/L;ZP#JV*[L'jnaXXiOP3MZD:mnSdp0&NFL7MDlQ.#%DHF,gqRQjCn5!6N9JXIF]"j5B@m-Z'B,cc0B8'`H(DWJA=LB$d.D7K(SW^S[#[FdL'3pHe`p.joZB+eqdecV]Up_Em?2%mA3Q,$U/LNK/@sC:)*N;nf<t7E#K1]nbG8oo\!J:4k>:<";CT\RF&k':f$7@ms!q--_cEUc4ps`iY9p"Rc0.Cl[egO\&t$H0;ic&W@b/>l."-1b]/+[ZXi+Lh=\@,<phTMSr'kF)C:pmSt3G;<tCYr1m<;I`=6LJH!c1cG9s)$:kkFo2;t=S)fUQP^;#p7<ZFtT=H=EZI#HT=,1q&qNWK;sL<UULm7X%3Z+Sm=,J1Tdh6cou,-/X$EX]Cs>uuSN"\D.>0@n^bW]s[&K<q+T@U0TBm`b>s0>,$-PJ@tqq;MU&lUS!T:,cd4n`Z3P&CWUR4.QD!3"@s]UH\t;p#PghWlt2Bs*+8u#oM#N(i#!Q-4.n2Dl@a&5<#SR'^\>CNrB&UVh0B~>endstream
endobj
17 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 2404
>>
stream
Gb!SmbAuXO'&EF8HE!]N%c8J:9:XVIQ.1%@R[lCA"Nd#T#n8",aaALHRL`9$*k&7i-:u=F6iocO%iXUf=onGD"oLP7M8#J-Ri__>6CKMTLqT1Q^X*M?X%ue_gG95Nd#TOt9iBjVZREY2B_I$38CF$@,gQ@M]QfUY;*\`=<T12:V(hM83/pkJ`eBP^p_lN2(V4]hIC:%X[7R@Lg)3'foTP>L>%3;l)^9;iG\I_C@><q/S47u,'hpp@oP;&3G?SD=*F:o?L4QgGUU6S4gA0q;GoDb\"OSJgn/:@CVL*\K5m*mOc0,DEet6\KP4%C)5,_@[CMg[T[BoYHn*^bFS]6d.pNZEl3-^&uoeMCLhoepAn%-&I5jnS3#;R4U+.hN_T/u&'q1[YIg^%5NAna6@9)EM*nJP.#,_mZah<D?BL-9Sn\bh3>fQsqTPdG@.[;ipM5Fsi!QqNlpiIRs2M3mgM68.Wej%Xbf8!f!#Dok;[&>'3VC7Z?k%45RQ6$#Uu'Q[!,q^>%;RcuOAH7eNO\Aqk4>;Q$N-k;3a=NaT\GU3N2:%"c5Ok-:3D/HCG4:"U;$iB^lKpdu`DnAOWdsG_F]FtfcqTkae?Op6u"^DlHRA[i/s+RWVmREP3C+IL/i'(8RFcTS?#Vp_..P[eQf_OZ7fp/_iK.>AUbWES/2Y"&\J=(d@@eJCBL68FtMD<aSATna'8r<t:CF[;,)MOHG4j'VR7ui@sB`PW,j^B.>:N$npD-cFji5UOqLR5^VfNW2P-<7V)AnS_tnD6[fEe5'G6'>PG,A'PL9q^o?MS7m%midh]ap>UK9A^,C32-qopg<rK]^d%X8(c?7Ou.<R\T<>iI_(,t^CAhmh""0\US.A:ljoT`S_O-=f0Q<eKC(I'S9oN<nH2CY/JB2ni5Yr"HnK7`4`)ubKtNkLW@Y93&T9nc%h@(_ou_B=Gjf)G!FN;j'hA-"X/"Y&1O1bGGGb*n=3eV]_TI..Le<+1%(]Yp?UaK5ShHUR$I(OSS-Y?i.%G\`EoG75%d!_gDD&k[;7#R'i2BV_I"cm\NiGj2k?G8,Q23$O;5lmlj]TcpRU^Oq)aR*4`kl&o>pCn]ZngiR7EGUfhU2tHY7*r^qH]B]4p*+&OZA(sH6PR=W;:rY^tmm#/U8lua9O3,d&%M3i)U"7XC[]/Z=!nC\l4rI2Tdd63,mffHbS[$L9qQ=,XCt7!;^TiUU_&BEWpNqG,&5T2Te1_!OA+bAUs^Vjbi`R,jN$Q]pF_W_lHr'[+,]>:s`^Ik;>0%Qsn\,%%MYO`^&$6;PYbl=rF7&]h\/aT@J((d.%q9(iT,Ao^Ik'l.DZp&KU1a*7_;Rfb2bBjpL\\F8c&k,5B#A1$pp]+NK[f.h-Onk6hJ<`s_k]-`q8B?#StBN?%L<(16.\e`o3;[9*`*qV^0uQann$Na$CkR9Hu1X/^;Z"J!?G16lf^0Qp7d%,l6^2`#EX'!B'0O[L-Yd;puYZO4cD>*i.5`W)Yf(K?\^\Pt1cB>l8u"g;4CcIf@c66=oV0Vl&mB'.T`Y*Q.3cJ\sL=ZO5?8krFqGP'R[4-*e4dC8FF'^E#7BtG/Hg9!g*U4r&P77V/^jh@;*ln;A/>?2$Ol8_dn!1U6^X#;B[Q,[gCf3eh563;%ilt)15L/u2+Eu7Mr'nLl0aXQeib]^3X3(X>(pM;b][V)MbI0]/9bfJZdIZ)6q!O52Qcje8o7MgG=D<h>fcV2Ear<uTO:+T2VM4h^J"W+'2`:W'%BB^u:JcSO)./-&ccJUsH0cFgt$2B<)0b62o55P!3ku":(,`b&P!q7!^p;-$&V;O>7d-nU\T]`M!g=Yc$?alLpI21r`Y$TWISTH.sQV1MiY4pC[(-bkbi\DRc1<HtbFI^7!%f#>.Hf4*5cNX+[Ru<`KEV6DBm3JH8Z)23qW,*]:<`@fLXR(:5Eesp-URNK$e$$mW>s1pbbu:S&6'jT:@.mhM4:g+6D71?6=G-M!@7U<K.c&Z)=ZuOf@qS8Kg]=iPE2-ZT&q<h[rG`1G1kd,[DW3$m;0[[q&pl9*[laCL#oZlRYnMJsp<kRr4OT"*\-nGPW<Ve"2h^fkRVH&g(!@M[_StAcW^PqY]I+3$)<@nMI:!!'SSI_@:bQQ&M.1gn4qNER'pn`t`Ep$-FE6hbJSqC.!niKm>K<XfTWES`XNR%#8T+D=N>2pQHj)cP9u##:!@4!BHD,Rh6C2d#*@DcC*/neu0;0>,4d+1Z$l1,ASh7\(2b28%DTOj6Aj>0+G3"9b8!\Zp#H`C029$O'+#NP2f>p08%3#&RBuD.=E]^K$Mlg&Ne'uKMS*[gMRhPHBhqUlZ6uEQ!ERYI:+Rb-(KRX-b7&ck!:"Qa#lJM:6:FpZcka-Wn1C0G#W<=OpBpe=W\oe^m"Fj:]'5M^_-i-cHV>~>endstream
endobj
18 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 2101
>>
stream
Gb!kua`?/p&A@B[qP^pD0M-5cS.nD3.S3d$jdRk6M&ATJ&emS\Nk[d6i&^F"YnC,P8SOS9!HpDPpNiSF*rbs&IpN0RF:Rbgn\GaoHjtPG69+uINm@B:pAA?2)R.!bP5l4K?qJKcI*N2-/G>5`"I:BCjFc.PebDDP=TfoB=;o4D]QKQ1_YVNKk60eO;5`T!#%))_>;>TS0]2IXPf%m@-UEEX5o/I)0?'P!'/C8#U"B?#`gp@4N0<q]jMhHR,a@Rl)KEU2"TQ!/>LKtVVNX6[q)+\W+Y-=XB?QJ!EJEt5e5gI<)1A>eF`nbb2WXF,<O-L"X=nf4=d<d$HY[rnTG"@`8sCI0<o\nV"[*tG*0h-p1o75U*$9dL];?>dX3pkDM]gp_Rnf>;^fo9_cD<97mtO&YeP6U1k(:Y/iht25Mqu`f79Q9lW,XKqY8T\6X_.('^39B"+kLjPF=Ck1*07in7t]1XhMC=:Y/Z`h]em<'DSB<rIe@E/.'c/Ac>ddTNjJKis/u)ubd5*GJakQ+&;A=93M$DE^#*<+r&KD5Rc3G49DI]T^#b'ZFk^-G>g1kIlLYQU_Mi%gnU-/!R3/?=#S[N6"oh,d385X=]6l_l_X..5W!>jF2[IT2r>eJiVtZ>9;ftS\Ju&ARF>n`oJPjsg5WfG6=$;.9FG4He;JCiTZ>_=U5$t*'7ORNrRo7c,g5ue@IUK:X<^[.G:e0FR(q(G_O.^D2KsW%EaN@r':q"cZ\l>LU"E'F'j_&n.=Sn`;m#HMq6$N9%BO8p<NTOrl]kEG3DunO.4iq@?Oe:&N#2Gd/TrsLI(0Xl#J*U?qbZds#MkSg_./arog^g>nppI^:D)S+h?c\SN]'MUr\3P<s\mkpdn5rnS*`TS(.F<>%Gk1f-D#o--@C>K2L#]&G0l\WV8hmX8^;*p:/WLRPLE+#($>6^M-^Rm?+(upFC4qNX[P^sA&D@;W]DE5dqm)_WV\])2!b!"PGd27>+2o,%.*T=(=GVE]cRpSQ0a'[(R@u]g)oRBHnVPNJic%d$8]9X5jTH<JEBaAmV,'9n<Yf4uTo[eC:--(BcCZBe94dTH)l(bkck5%f@j*.P^dGd`BGfW8fd"c4,F4=.9p,EmP?H5&UC'aCWsD[De0cgu!ai:!dAR,;NcG^OUO.P8(1E*q`TJKhh*cZ[nC;U"J2EMSnsnT(!1"+RZI)]I;BPC:C%&CLV8P*:B!bn-_kLgd@WY0)H+]LjM4aFl>>AuSq9"Q=1Db+UM8H@%=2s9;Aa]C\2hsegBX4*tIa#Hka.qQ!G]_Ah;M3rW"j1JqTar'_2OEtVP%?.aiu`dN7)>gd\^Il.hgVVbk[Q[%#>*9'kBt_bA/.rEab1ohd'q?(h'ecYGI#LK!'Cta?NXBBCnedAY4+,WiQL6gIfcbml?]R+,q/%0SM:&'ahkBXcN3/r>8C[3f3f5^M_abDWFf-EAZR_t/p:pO0Q?\N#k(e.G.-DQOBPFJ\h#;1.3tR`0NXB!7o;8niDfbVDQ*!PVK&M?2siTN-<BN0?')uUI<i1-T7hB@>XJZ?k2E^RCZ=8.@[a><QIpp](Ob>aN1D%IT"amj#nG62RE.g2+4a[Ko!T5O=fj$>@C=m6-qjr1,B>>)qV([DF""hU2gpcT?F!RD^(:cC:V`Y[C1Bj<@n5'7WEX)/%>dh7?Rr(K3Dh3>OrLY-9_H3o.ZN43/@^HVcIq!=`L(de:OdKOVi$='6O.c<NNcGk+IAHMR\:fkrS:[\p,o,[l5NO%0)M9j<e9EsgnVXg^;M']quifW*]d>$>W$"CZONdE6bq`mg,dom&B)S1(pgGs3^07UY+X'u`$KPlB)+oUD\)>ZM+*`u&rdf4cDdP2>Z0'QIH9\*+KgNIjdDdf]n`'lECpC<(r%ak,u6e)UX(^aaBeOJHoC']5k^R3f(NnY:s2YkFK1HW;#i<4[%6OSe[DLulWcPY/K,En-`j6./9'f_XMD4s*#%_A_YEl*q%NW]4_OoWE>f(lQB9t@EVp4Y0?P'gGAf2k3dq>5`@6Oh,UI&g@1^!$BD\=&T&,.7*F:<Kglm)Cehq'\2W+TP^/0O5Ak@pah,8A^Y&T(s$#Cu?YkL(FItF3~>endstream
endobj
19 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 2061
>>
stream
Gatm<9lo&I&A@sBm*X';/-12Ah)%^PG/fB@jHL$?64,LoToc#6k1B>ZpaJ^2UlHZ]'F>>_f\Q;q_1Gp>K`++eM1/[UYAXOM8W+;ihskFN2n`O&ICg-jbf"^9+HhO)krE2="aCQqNfYFj'64.=E@!Vo'`4JpH@8fP[88n-8V&gafVb&kUfu74"W$21-4P-(CKLLC2("r^kY8T3#mE=UG``DRQ&1QG\n/gi,EETd<g=pu[i8ZH3rXgK?YT%^fDgA^CZja6ad>bJ7k_@9O#Gg^c.bspX%_-0RZbCmWlQu]Bu6e$RW#<WimMY^R%t*3Y\67M^rm*k'S2Hi?=J1-l2pBuf$WCJ9'9F-^pR0Hj4\nSI*[nSi,bYK*\$?kYQK[K)J$-fMle#S)13lIUM<5T2YC]9oH*U%;5VgT?Rd"=Hen/iL;U=5.iMd[X`g:hK=Cb+I_T2L>Z4rs:)r78?PD9Ph7pXDJq3igC98c=%@nm[;0QBoK,o's,OXDuG<U(+kKm,B7;'gPpPmjQT(-62]o0Lp3/JH^Jg(eeYW4ZdN2&O@SX7l7/A.?+._C)blZK<J1)jN"1cL]B)q:LWWlrB$A4U?c_Sj?'XYSEHLj`WedZL[S8b0R9E@:dL+H/[KS0[_O2oHG;qT/_bBd)Pa/uMACq*C)>&U$B/^6JU&6P%0^'(<HV0=\l4O;s%i%0kjB#fb9j$!<><%A(i"mA@s'Kg8.6YU`(ZCUNtY,/bai(eD;ujZ7]IK]Z?dQ"fg2"k>NBg-2@&"#1AW1QomN0LBW^G&'ohV$6b1=5XIW1AFM,,`)#t(W\)NeI@clhS*$W"`m*r-Lf`r>@sgh^HOOc/QB:!CSDfGY>Ogh@G.OBa;.":g?L_#"dufsS3p73js!@#Olsm+K.8Y+J6o9n^,'D4^lL`9c^'2bVV:04X(]O9*!A?/oJGB9+T.[\]eOXG_A&26Q]COG#%cuB``0ike`cu)VGNQWW_*tj3>kGrIdtXUr!A[d5e=365@5OO]BH=na=E9A",p/bJO<:HW%c0"i-=Gln&/'ll#XKnmW)<3V,JBnAr.:lep=5##dKl:cQ]Rl4V^obnV&RS6qHdnh7#V0AIp"=(YP%X,EIVcafDd,<W7D9PL^KFjAE`M0JH",`PIKm_E^&e;Tg!K8)&(JFeK$+gGd1JhS$tQO'%XQJr+mT"h-$Xph_RGZb6+ZlOYDn/7EX/.#5L)PtauV*^5Pkk%L2&A!]9B+AZ5n=H=in*_a#d/^RUh_;QloH$omZ>Y&;$&p?2gBZ&5M+"7,:G#0,!J`NJMk4/KeFrm0+V!b+EO+A=\`JB,>8i_!/^S5^D'iJg,^e/p(e\US,1+^2t^41>/6K?;647At(/Ttp;AJIh*Q)lK7UJ'\Ge`JtGnmdM>6\G*4YZ!^HUjKZXHQmPKH7D8E;eM;S/ga$Z`0Pg*%#ZO%&+]*IjL]([o0N7]*-MLAF$=C^%)[Y^m&b*m]0QISCMT!e*cEC7O(r/HIDS.?NCWC#6$ielmHGt&la-:qI>4dEi5qWT@h/m6Uo1o>:jh8"GbH2M#Q.+b(cPQ`%9jA_oO,YR0RuU3blC.M`Aop?"I(PCZ,aPO#B@Ufa-.ns+\OCkl@606L[K:pMObZINUm0d[&sf$?6oUjFUjB2b4Gia1#)9(@GR[@5rVm\JEA]jboYp?=BDWq]&^<>F"HT-T9%@3!Aj$ULa<nC7#gE36-dR#ehANBPNc\GgZrs)*q=OSY%\==h/0&qNZC3JWmS9PQG]DcO5Fc$O'(u8"S**'M1^@$ot\$o2?SZH57@1kR>4X(:,n+P:F%/E%Si+BR%rj2Xu)\$ARA-)/Z(IPaT[VY3.@rtN!Ic10J;?pQ2DO0*S/]JlS.!;Tr_!ShsL]u(,\IS@!(TQqEoH6L+6ROpHsS\O1+l>F;"A-DIkoX/m#J+ft+Y%],[7Q$P+js`Q&QX[T(_UCPS=t6F5-;G+o545*+E`bn3R<Y\17E/:F]t"KEa@ARJ[+qBOe[HEEm-Zmsd"1](rR_+lp@cu3F9nD)(QbeojJ!%+F:LARk)1OH3k!5lL0dFE`][2Y)~>endstream
endobj
xref
0 20
0000000000 65535 f
0000000061 00000 n
0000000112 00000 n
0000000219 00000 n
0000000331 00000 n
0000000408 00000 n
0000000613 00000 n
0000000818 00000 n
0000001023 00000 n
0000001228 00000 n
0000001433 00000 n
0000001639 00000 n
0000001709 00000 n
0000001990 00000 n
0000002081 00000 n
0000003500 00000 n
0000005802 00000 n
0000007885 00000 n
0000010381 00000 n
0000012574 00000 n
trailer
<<
/ID
[<3d879f6902a83f70d587d7d3563c8f7d><3d879f6902a83f70d587d7d3563c8f7d>]
% ReportLab generated PDF document -- digest (opensource)
/Info 12 0 R
/Root 11 0 R
/Size 20
>>
startxref
14727
%%EOF

View File

@@ -2,7 +2,7 @@ import React, { Suspense } from 'react';
import { Routes, Route, Navigate, useLocation } from 'react-router-dom'; import { Routes, Route, Navigate, useLocation } from 'react-router-dom';
import { Loader2 } from 'lucide-react'; import { Loader2 } from 'lucide-react';
import Layout from './components/Layout'; import Layout from './components/Layout';
import { isAuthenticated, isSuperAdmin } from './dataService'; import { isAuthenticated } from './dataService';
const Dashboard = React.lazy(() => import('./pages/Dashboard')); const Dashboard = React.lazy(() => import('./pages/Dashboard'));
const Products = React.lazy(() => import('./pages/Products')); const Products = React.lazy(() => import('./pages/Products'));
@@ -12,6 +12,8 @@ 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 PlanningIssues = React.lazy(() => import('./pages/PlanningIssues'));
const ProductionOrders = React.lazy(() => import('./pages/ProductionOrders')); const ProductionOrders = React.lazy(() => import('./pages/ProductionOrders'));
const ProductionPlans = React.lazy(() => import('./pages/ProductionPlans'));
const ProductionQuality = React.lazy(() => import('./pages/ProductionQuality'));
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'));
const ClientDetails = React.lazy(() => import('./pages/ClientDetails')); const ClientDetails = React.lazy(() => import('./pages/ClientDetails'));
@@ -32,13 +34,6 @@ function PrivateRoute({ children }: { children: React.ReactNode }) {
return children; return children;
} }
function SuperAdminRoute({ children }: { children: React.ReactNode }) {
if (!isSuperAdmin()) {
return <Navigate to="/graph" replace />;
}
return children;
}
const RouteFallback = () => ( const RouteFallback = () => (
<div className="flex min-h-screen items-center justify-center bg-dark-bg text-brand-primary"> <div className="flex min-h-screen items-center justify-center bg-dark-bg text-brand-primary">
<Loader2 className="h-8 w-8 animate-spin" /> <Loader2 className="h-8 w-8 animate-spin" />
@@ -64,6 +59,8 @@ function App() {
<Route path="stock" element={<Navigate to="/products" replace />} /> <Route path="stock" element={<Navigate to="/products" replace />} />
<Route path="stock-alerts" element={<Navigate to="/products" replace />} /> <Route path="stock-alerts" element={<Navigate to="/products" replace />} />
<Route path="production-orders" element={<ProductionOrders />} /> <Route path="production-orders" element={<ProductionOrders />} />
<Route path="production-plans" element={<ProductionPlans />} />
<Route path="production-quality" element={<ProductionQuality />} />
<Route path="clients" element={<Clients />} /> <Route path="clients" element={<Clients />} />
<Route path="clients/:clientToken" element={<ClientDetails />} /> <Route path="clients/:clientToken" element={<ClientDetails />} />
<Route path="sellers" element={<Sellers />} /> <Route path="sellers" element={<Sellers />} />
@@ -71,8 +68,8 @@ function App() {
<Route path="rfm" element={<Rfm />} /> <Route path="rfm" element={<Rfm />} />
<Route path="campaigns" element={<Campaigns />} /> <Route path="campaigns" element={<Campaigns />} />
<Route path="registrations" element={<Registrations />} /> <Route path="registrations" element={<Registrations />} />
<Route path="admin/users" element={<SuperAdminRoute><AdminUsers /></SuperAdminRoute>} /> <Route path="admin/users" element={<AdminUsers />} />
<Route path="admin/olist" element={<SuperAdminRoute><OlistSync /></SuperAdminRoute>} /> <Route path="admin/olist" element={<OlistSync />} />
</Route> </Route>
</Routes> </Routes>
</Suspense> </Suspense>

View File

@@ -45,7 +45,9 @@ const productionOrder = (overrides: Partial<ProductionOrderItem>): ProductionOrd
test('classifyCutFamily maps known product bases to internal cut families', () => { test('classifyCutFamily maps known product bases to internal cut families', () => {
assert.equal(classifyCutFamily('BASE LISA CAMISETA').key, 'BLCS'); assert.equal(classifyCutFamily('BASE LISA CAMISETA').key, 'BLCS');
assert.equal(classifyCutFamily('BASE LISA CAMISETA OVER').key, 'BLOS'); assert.equal(classifyCutFamily('BASE LISA CAMISETA OVER').key, 'BLOS');
assert.equal(classifyCutFamily('BASE LISA MOLETOM CANGURU').key, 'BLPM'); assert.equal(classifyCutFamily('BASE LISA MOLETOM CANGURU').key, 'BLMC');
assert.equal(classifyCutFamily('BASE LISA CAMISETA PIMA').key, 'BLPM');
assert.equal(classifyCutFamily('BASE LISA REGATA MACHAO').key, 'BLRM');
assert.equal(classifyCutFamily('BONÉ').key, 'OUTROS'); assert.equal(classifyCutFamily('BONÉ').key, 'OUTROS');
}); });

View File

@@ -70,10 +70,10 @@ export interface CutPlan {
export const CUT_FAMILY_RULES: CutFamilyRule[] = [ export const CUT_FAMILY_RULES: CutFamilyRule[] = [
{ {
key: 'BLPM', key: 'BLMC',
label: 'Moletom', label: 'Moletom canguru',
materialLabel: 'BLPM', materialLabel: 'BLMC',
keywords: ['MOLETOM'], keywords: ['MOLETOM CANGURU', 'MOLETOM'],
unitsPerRoll: null unitsPerRoll: null
}, },
{ {
@@ -84,10 +84,17 @@ export const CUT_FAMILY_RULES: CutFamilyRule[] = [
unitsPerRoll: null unitsPerRoll: null
}, },
{ {
key: 'BLMC', key: 'BLPM',
label: 'Camiseta infantil', label: 'Camiseta Pima',
materialLabel: 'BLMC', materialLabel: 'BLPM',
keywords: ['INFANTIL', 'KIDS'], keywords: ['CAMISETA PIMA', 'PIMA'],
unitsPerRoll: null
},
{
key: 'BLRM',
label: 'Regata',
materialLabel: 'BLRM',
keywords: ['REGATA'],
unitsPerRoll: null unitsPerRoll: null
}, },
{ {
@@ -149,8 +156,11 @@ const normalizeMatchText = (value: string) => normalizeProductText(value)
const isOpenProductionOrder = (order: ProductionOrderItem) => ( const isOpenProductionOrder = (order: ProductionOrderItem) => (
order.status === 'open' || order.status === 'open' ||
order.status === 'cutting' ||
order.status === 'sewing' ||
order.status === 'review' ||
order.status === 'in_progress' || order.status === 'in_progress' ||
['em aberto', 'aberta', 'andamento', 'em andamento'].includes(normalizeMatchText(order.status)) ['em aberto', 'aberta', 'andamento', 'em andamento', 'em corte', 'em costura', 'em revisao'].includes(normalizeMatchText(order.status))
); );
export const buildOpenProductionByProductId = ( export const buildOpenProductionByProductId = (

View File

@@ -2,7 +2,7 @@ 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, ClipboardList, Menu, X, BriefcaseBusiness, Radio } from 'lucide-react'; import { LayoutDashboard, Users, BarChart3, ChevronLeft, ChevronRight, Package, LogOut, Megaphone, Grid3X3, Shield, Moon, Sun, Tags, Boxes, ClipboardList, Menu, X, BriefcaseBusiness, Radio } from 'lucide-react';
import type { DateRange, OrderData } from '../types'; import type { DateRange, OrderData } from '../types';
import { isSuperAdmin, logout } from '../dataService'; import { logout } from '../dataService';
import { rangeForLastDays } from '../dateRanges'; import { rangeForLastDays } from '../dateRanges';
const emptyOrdersData: OrderData[] = []; const emptyOrdersData: OrderData[] = [];
@@ -61,12 +61,10 @@ const Layout = () => {
}; };
const showSidebarLabels = !isSidebarCollapsed || isMobileNavOpen; const showSidebarLabels = !isSidebarCollapsed || isMobileNavOpen;
const adminNavigation = isSuperAdmin() const adminNavigation = [
? [ { name: 'Olist', href: '/admin/olist', icon: Radio },
{ name: 'Olist', href: '/admin/olist', icon: Radio }, { name: 'Usuários', href: '/admin/users', icon: Shield }
{ name: 'Usuários', href: '/admin/users', icon: Shield } ];
]
: [];
const navigationSections = [ const navigationSections = [
{ {
label: 'Painel', label: 'Painel',
@@ -92,7 +90,7 @@ const Layout = () => {
{ name: 'Campanhas', href: '/campaigns', icon: Megaphone }, { name: 'Campanhas', href: '/campaigns', icon: Megaphone },
], ],
}, },
...(adminNavigation.length ? [{ label: 'Super admin', items: adminNavigation }] : []), { label: 'Administração', items: adminNavigation },
]; ];
return ( return (

View File

@@ -13,6 +13,7 @@ type PaginationControlsProps = {
onPageChange: (page: number) => void; onPageChange: (page: number) => void;
onPageSizeChange: (pageSize: number) => void; onPageSizeChange: (pageSize: number) => void;
className?: string; className?: string;
stacked?: boolean;
}; };
const clampPage = (page: number, totalPages: number) => { const clampPage = (page: number, totalPages: number) => {
@@ -32,7 +33,8 @@ const PaginationControls = ({
endIndex, endIndex,
onPageChange, onPageChange,
onPageSizeChange, onPageSizeChange,
className = 'px-6 py-4 border-t border-zinc-100 dark:border-dark-border' className = 'px-6 py-4 border-t border-zinc-100 dark:border-dark-border',
stacked = false
}: PaginationControlsProps) => { }: PaginationControlsProps) => {
const safeTotalPages = Math.max(1, totalPages); const safeTotalPages = Math.max(1, totalPages);
const safeCurrentPage = clampPage(currentPage, safeTotalPages); const safeCurrentPage = clampPage(currentPage, safeTotalPages);
@@ -44,7 +46,7 @@ const PaginationControls = ({
}; };
return ( return (
<div className={`${className} flex flex-col items-center justify-between gap-4 lg:flex-row`}> <div className={`${className} ${stacked ? 'flex flex-col items-center gap-3' : 'flex flex-col items-center justify-between gap-4 lg:flex-row'}`}>
<div className="flex flex-wrap items-center justify-center gap-2 text-sm text-zinc-500 dark:text-dark-muted"> <div className="flex flex-wrap items-center justify-center gap-2 text-sm text-zinc-500 dark:text-dark-muted">
<span>Mostrar</span> <span>Mostrar</span>
<select <select
@@ -59,7 +61,7 @@ const PaginationControls = ({
<span>{pageSizeLabel}</span> <span>{pageSizeLabel}</span>
</div> </div>
<div className="flex flex-wrap items-center justify-center gap-3 text-sm"> <div className={stacked ? 'flex flex-col items-center gap-2 text-sm' : 'flex flex-wrap items-center justify-center gap-3 text-sm'}>
<span className="text-center text-zinc-500 dark:text-dark-muted"> <span className="text-center text-zinc-500 dark:text-dark-muted">
Mostrando {totalItems > 0 ? startIndex + 1 : 0} a {endIndex} de {totalItems} {itemLabel} Mostrando {totalItems > 0 ? startIndex + 1 : 0} a {endIndex} de {totalItems} {itemLabel}
</span> </span>

View File

@@ -0,0 +1,125 @@
import { useCallback, useEffect, useState } from 'react';
import { History, RotateCcw, Save, X } from 'lucide-react';
import { deleteGraphsStockBalance, fetchGraphsStockBalanceHistory, saveGraphsStockBalance } from '../dataService';
import type { GraphsStockBalanceHistoryItem, StockData } from '../types';
const formatNumber = (value: number, maximumFractionDigits = 0) => (
new Intl.NumberFormat('pt-BR', { maximumFractionDigits }).format(value)
);
const formatDate = (value: string | null | undefined) => {
if (!value) return '—';
const date = new Date(value);
return Number.isNaN(date.getTime()) ? '—' : new Intl.DateTimeFormat('pt-BR', { dateStyle: 'short' }).format(date);
};
type StockGraphsBalanceModalProps = {
stock: StockData;
title?: string;
onClose: () => void;
onSaved: (stock: StockData) => void;
};
const StockGraphsBalanceModal = ({ stock, title, onClose, onSaved }: StockGraphsBalanceModalProps) => {
const [balance, setBalance] = useState(stock.graphs_balance === null || stock.graphs_balance === undefined ? '' : String(stock.graphs_balance));
const [reason, setReason] = useState(stock.graphs_balance_reason || 'Conferência física de estoque');
const [effectiveAt, setEffectiveAt] = useState(stock.graphs_balance_effective_at?.slice(0, 10) || new Date().toISOString().slice(0, 10));
const [history, setHistory] = useState<GraphsStockBalanceHistoryItem[]>([]);
const [isLoadingHistory, setIsLoadingHistory] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const [error, setError] = useState('');
const loadHistory = useCallback(async () => {
setIsLoadingHistory(true);
try {
setHistory(await fetchGraphsStockBalanceHistory(stock.produto_id));
} catch (historyError) {
setError(historyError instanceof Error ? historyError.message : 'Não foi possível carregar o histórico.');
} finally {
setIsLoadingHistory(false);
}
}, [stock.produto_id]);
useEffect(() => {
const timer = window.setTimeout(() => { void loadHistory(); }, 0);
return () => window.clearTimeout(timer);
}, [loadHistory]);
const save = async () => {
const normalizedBalance = Number(balance.replace(',', '.'));
if (!Number.isFinite(normalizedBalance)) {
setError('Informe um saldo físico válido.');
return;
}
setIsSaving(true);
setError('');
try {
const saved = await saveGraphsStockBalance(stock.produto_id, { balance: normalizedBalance, reason, effectiveAt });
onSaved(saved);
setBalance(String(saved.graphs_balance ?? normalizedBalance));
await loadHistory();
} catch (saveError) {
setError(saveError instanceof Error ? saveError.message : 'Não foi possível salvar o saldo Graphs.');
} finally {
setIsSaving(false);
}
};
const remove = async () => {
if (!window.confirm('Remover o saldo Graphs e voltar a usar o saldo importado do Tiny no planejamento?')) return;
setIsSaving(true);
setError('');
try {
const saved = await deleteGraphsStockBalance(stock.produto_id);
onSaved(saved);
setBalance('');
await loadHistory();
} catch (removeError) {
setError(removeError instanceof Error ? removeError.message : 'Não foi possível remover o saldo Graphs.');
} finally {
setIsSaving(false);
}
};
const planningBalance = Math.max(0, Number(stock.planning_balance ?? stock.saldo));
const materialName = title || stock.nome;
return <div className="fixed inset-0 z-[60] flex h-dvh items-center justify-center overflow-y-auto bg-black/75 p-4 backdrop-blur-md" role="dialog" aria-modal="true" aria-label={`Saldo Graphs de ${materialName}`} 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">
<p className="font-mono text-[10px] font-bold uppercase tracking-widest text-dark-muted">Material #{stock.produto_id}</p>
<h2 className="mt-1 truncate text-lg font-bold text-dark-text" title={materialName}>{materialName}</h2>
<p className="mt-2 text-sm font-semibold text-dark-muted">O saldo Tiny permanece preservado para auditoria. O saldo Graphs passa a ser a base do planejamento.</p>
</div>
<button type="button" onClick={onClose} title="Fechar" aria-label="Fechar" 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"><X className="h-4 w-4" /></button>
</div>
<div className="grid gap-3 p-5 sm:grid-cols-3">
<div className="rounded-xl border border-dark-border bg-dark-input/50 p-3"><p className="text-[10px] font-bold uppercase tracking-widest text-dark-muted">Saldo Tiny</p><p className="mt-2 text-lg font-bold text-red-300">{formatNumber(Number(stock.saldo), 2)} un.</p></div>
<div className="rounded-xl border border-dark-border bg-dark-input/50 p-3"><p className="text-[10px] font-bold uppercase tracking-widest text-dark-muted">Saldo Graphs</p><p className="mt-2 text-lg font-bold text-brand-primary">{stock.graphs_balance === null || stock.graphs_balance === undefined ? 'Não informado' : `${formatNumber(Number(stock.graphs_balance), 2)} un.`}</p></div>
<div className="rounded-xl border border-dark-border bg-dark-input/50 p-3"><p className="text-[10px] font-bold uppercase tracking-widest text-dark-muted">Disponível no plano</p><p className="mt-2 text-lg font-bold text-emerald-300">{formatNumber(planningBalance, 2)} un.</p></div>
</div>
<div className="grid gap-4 border-t border-dark-border p-5 md:grid-cols-[minmax(0,1fr)_minmax(0,1.2fr)]">
<div className="space-y-4">
<label className="block space-y-2"><span className="text-xs font-bold uppercase tracking-widest text-dark-muted">Saldo físico no Graphs</span><input type="text" inputMode="decimal" value={balance} onChange={(event) => setBalance(event.target.value)} placeholder="Ex.: 850,50" 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="block space-y-2"><span className="text-xs font-bold uppercase tracking-widest text-dark-muted">Data da conferência</span><input type="date" value={effectiveAt} onChange={(event) => setEffectiveAt(event.target.value)} 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" /></label>
<label className="block space-y-2"><span className="text-xs font-bold uppercase tracking-widest text-dark-muted">Motivo</span><textarea value={reason} onChange={(event) => setReason(event.target.value)} rows={3} placeholder="Ex.: inventário físico do depósito" 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>
{error && <p className="rounded-lg border border-red-400/30 bg-red-400/10 px-3 py-2 text-xs font-bold text-red-300">{error}</p>}
<div className="flex flex-wrap justify-between gap-2"><button type="button" onClick={() => void remove()} disabled={isSaving || stock.graphs_balance === null || stock.graphs_balance === undefined} className="inline-flex h-10 items-center gap-2 rounded-lg border border-dark-border px-3 text-sm font-bold text-dark-muted transition-colors hover:border-red-400/40 hover:text-red-300 disabled:cursor-not-allowed disabled:opacity-40 cursor-pointer"><RotateCcw className="h-4 w-4" /> Usar Tiny</button><button type="button" onClick={() => void save()} disabled={isSaving} className="inline-flex h-10 items-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 cursor-pointer"><Save className="h-4 w-4" /> {isSaving ? 'Salvando...' : 'Salvar saldo Graphs'}</button></div>
</div>
<div className="overflow-hidden rounded-xl border border-dark-border">
<div className="flex items-center gap-2 border-b border-dark-border bg-dark-input/50 px-4 py-3"><History className="h-4 w-4 text-brand-primary" /><h3 className="text-sm font-bold text-dark-text">Histórico de alterações</h3></div>
<div className="max-h-80 divide-y divide-dark-border overflow-y-auto">
{isLoadingHistory && <p className="p-4 text-sm font-semibold text-dark-muted">Carregando histórico...</p>}
{!isLoadingHistory && !history.length && <p className="p-4 text-sm font-semibold text-dark-muted">Nenhuma alteração manual registrada.</p>}
{history.map(item => <div key={item.id} className="p-4"><div className="flex items-center justify-between gap-3"><p className="text-sm font-bold text-dark-text">{item.action === 'removed' ? 'Saldo Graphs removido' : `${formatNumber(Number(item.graphs_balance || 0), 2)} un.`}</p><span className="text-xs font-semibold text-dark-muted">{formatDate(item.created_at)}</span></div><p className="mt-1 text-xs font-semibold text-dark-muted">Tiny: {formatNumber(Number(item.tiny_balance || 0), 2)} un. · referência {formatDate(item.effective_at)}</p>{item.reason && <p className="mt-2 text-xs font-medium text-dark-text">{item.reason}</p>}{item.changed_by && <p className="mt-1 text-[11px] font-semibold text-dark-muted">Por {item.changed_by}</p>}</div>)}
</div>
</div>
</div>
</div>
</div>;
};
export default StockGraphsBalanceModal;

View File

@@ -1,4 +1,4 @@
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, OlistRunDetails, OlistStatus, ProductAnalyticsItem, ProductComposition, ProductCompositionImportSummary, ProductDetailsAnalytics, ProductionOrderImportData, ProductionOrderItem, ProductionOrderPayload, ProductionOrderStatus, ProductionOrderSummary, RfmAnalytics, SellerAnalyticsItem, SellerDetailsAnalytics, StockData, SupplyFabricPlan, SupplyFabricPlanPayload, SupplyInventoryAdjustmentPayload, SupplyLot, SupplyMaterialDetailsPayload, 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, CutPlanLotReservationPayload, CuttingSettings, DashboardAnalytics, DateRange, GraphsStockBalanceHistoryItem, GraphsStockBalancePayload, ManagedUser, MaterialTransformationStatus, OperationalUser, OrderData, OlistRunDetails, OlistStatus, ProductAnalyticsItem, ProductComposition, ProductCompositionImportSummary, ProductCompositionPayload, ProductDetailsAnalytics, ProductionOrderDefectPayload, ProductionOrderExecutionPayload, ProductionOrderImportData, ProductionOrderItem, ProductionOrderPayload, ProductionOrderStatus, ProductionOrderSummary, ProductionPlan, ProductionPlanPayload, ProductionPlanReviewPayload, ProductionWorkstation, ProductionWorkstationPayload, RfmAnalytics, SellerAnalyticsItem, SellerDetailsAnalytics, StockData, SupplyFabricPlan, SupplyFabricPlanPayload, SupplyInventoryAdjustmentPayload, SupplyLot, SupplyMaterialDetailsPayload, SupplyMaterialTransformation, SupplyMaterialTransformationPayload, SupplyProductionExitPayload, SupplyPurchaseNeed, SupplyPurchaseOrder, SupplyPurchaseOrderPayload, SupplyPurchaseOrderStatus, 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';
@@ -128,7 +128,7 @@ export const fetchStock = async (): Promise<StockData[]> => {
'Authorization': `Bearer ${token}` 'Authorization': `Bearer ${token}`
} }
}); });
if (response.status === 401 || response.status === 403) { if (response.status === 401) {
logout(); logout();
return []; return [];
} }
@@ -139,6 +139,30 @@ export const fetchStock = async (): Promise<StockData[]> => {
} }
}; };
export const fetchGraphsStockBalanceHistory = async (productId: string): Promise<GraphsStockBalanceHistoryItem[]> => {
const response = await authFetch(`/stock/${encodeURIComponent(productId)}/graphs-balance-history`);
if (!response.ok) throw new Error('Não foi possível consultar o histórico de saldo Graphs.');
return response.json() as Promise<GraphsStockBalanceHistoryItem[]>;
};
export const saveGraphsStockBalance = async (productId: string, payload: GraphsStockBalancePayload): Promise<StockData> => {
const response = await authFetch(`/stock/${encodeURIComponent(productId)}/graphs-balance`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
const data = await response.json().catch(() => null) as StockData | { error?: string } | null;
if (!response.ok) throw new Error((data as { error?: string } | null)?.error || 'Não foi possível salvar o saldo Graphs.');
return data as StockData;
};
export const deleteGraphsStockBalance = async (productId: string): Promise<StockData> => {
const response = await authFetch(`/stock/${encodeURIComponent(productId)}/graphs-balance`, { method: 'DELETE' });
const data = await response.json().catch(() => null) as StockData | { error?: string } | null;
if (!response.ok) throw new Error((data as { error?: string } | null)?.error || 'Não foi possível remover o saldo Graphs.');
return data as StockData;
};
export const fetchData = async (): Promise<OrderData[]> => { export const fetchData = async (): Promise<OrderData[]> => {
try { try {
const token = localStorage.getItem('auth_token'); const token = localStorage.getItem('auth_token');
@@ -147,7 +171,7 @@ export const fetchData = async (): Promise<OrderData[]> => {
'Authorization': `Bearer ${token}` 'Authorization': `Bearer ${token}`
} }
}); });
if (response.status === 401 || response.status === 403) { if (response.status === 401) {
logout(); logout();
return []; return [];
} }
@@ -169,7 +193,10 @@ const authFetch = async (path: string, options: RequestInit = {}): Promise<Respo
} }
}); });
if (response.status === 401 || response.status === 403) { // A 403 means the signed-in user lacks permission for this specific
// resource. Keep their session intact so protected optional data cannot
// force a logout from an otherwise accessible page.
if (response.status === 401) {
logout(); logout();
} }
@@ -195,31 +222,32 @@ export const downloadDatabaseDiagnostic = async (): Promise<void> => {
export const fetchProductionOrders = async ( export const fetchProductionOrders = async (
dateRange: DateRange, dateRange: DateRange,
filters?: { search?: string }, filters?: { search?: string; activeOnly?: boolean },
options?: CacheOptions options?: CacheOptions
): Promise<ProductionOrderSummary> => { ): Promise<ProductionOrderSummary> => {
const params = buildDateRangeParams(dateRange); const params = buildDateRangeParams(dateRange);
const search = filters?.search?.trim(); const search = filters?.search?.trim();
if (search) params.set('search', search); if (search) params.set('search', search);
if (filters?.activeOnly) params.set('activeOnly', 'true');
const path = `/production-orders?${params.toString()}`; const path = `/production-orders?${params.toString()}`;
return getCachedAnalytics(path, async () => { return getCachedAnalytics(path, async () => {
try { try {
const response = await authFetch(path, options?.force ? { cache: 'no-store' } : {}); const response = await authFetch(path, options?.force ? { cache: 'no-store' } : {});
if (!response.ok) return { orders: [], counts: { all: 0, open: 0, in_progress: 0, finished: 0, canceled: 0 } }; if (!response.ok) return { orders: [], counts: { all: 0, open: 0, cutting: 0, sewing: 0, review: 0, finished: 0, canceled: 0 } };
return await response.json(); return await response.json();
} catch (error) { } catch (error) {
console.error('Fetch production orders failed', error); console.error('Fetch production orders failed', error);
return { orders: [], counts: { all: 0, open: 0, in_progress: 0, finished: 0, canceled: 0 } }; return { orders: [], counts: { all: 0, open: 0, cutting: 0, sewing: 0, review: 0, finished: 0, canceled: 0 } };
} }
}, options); }, options);
}; };
export const createProductionOrders = async (orders: ProductionOrderPayload[]): Promise<CreateProductionOrdersResult> => { export const createProductionOrders = async (orders: ProductionOrderPayload[], lotReservations: CutPlanLotReservationPayload[] = [], plan?: ProductionPlanPayload): Promise<CreateProductionOrdersResult> => {
const response = await authFetch('/production-orders', { const response = await authFetch('/production-orders', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ orders }) body: JSON.stringify({ orders, lotReservations, plan })
}); });
if (!response.ok) throw new Error('Não foi possível criar as ordens de produção.'); if (!response.ok) throw new Error('Não foi possível criar as ordens de produção.');
@@ -227,6 +255,46 @@ export const createProductionOrders = async (orders: ProductionOrderPayload[]):
return await response.json(); return await response.json();
}; };
export const fetchProductionPlans = async (): Promise<ProductionPlan[]> => {
const response = await authFetch('/production-plans', { cache: 'no-store' });
if (!response.ok) throw new Error('Não foi possível carregar os planos de produção.');
return response.json() as Promise<ProductionPlan[]>;
};
export const updateProductionPlan = async (id: number, payload: ProductionPlanPayload): Promise<ProductionPlan> => {
const response = await authFetch(`/production-plans/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
const data = await response.json().catch(() => null);
if (!response.ok) throw new Error(data?.error || 'Não foi possível atualizar o plano de produção.');
analyticsCache.clear();
return data as ProductionPlan;
};
export const updateProductionPlanStatus = async (id: number, status: ProductionOrderStatus): Promise<ProductionPlan> => {
const response = await authFetch(`/production-plans/${id}/status`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status })
});
const data = await response.json().catch(() => null);
if (!response.ok) throw new Error(data?.error || 'Não foi possível mover o plano de produção.');
analyticsCache.clear();
return data as ProductionPlan;
};
export const saveProductionPlanReview = async (id: number, payload: ProductionPlanReviewPayload): Promise<ProductionPlan> => {
const response = await authFetch(`/production-plans/${id}/review`, {
method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload)
});
const data = await response.json().catch(() => null);
if (!response.ok) throw new Error(data?.error || 'Não foi possível salvar a revisão operacional.');
analyticsCache.clear();
return data as ProductionPlan;
};
export const updateProductionOrderStatus = async (id: number, status: ProductionOrderStatus): Promise<ProductionOrderItem> => { export const updateProductionOrderStatus = async (id: number, status: ProductionOrderStatus): Promise<ProductionOrderItem> => {
const response = await authFetch(`/production-orders/${id}/status`, { const response = await authFetch(`/production-orders/${id}/status`, {
method: 'PATCH', method: 'PATCH',
@@ -239,6 +307,59 @@ export const updateProductionOrderStatus = async (id: number, status: Production
return await response.json(); return await response.json();
}; };
export const updateProductionOrderExecution = async (id: number, payload: ProductionOrderExecutionPayload): Promise<ProductionOrderItem> => {
const response = await authFetch(`/production-orders/${id}/execution`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
const data = await response.json().catch(() => null);
if (!response.ok) throw new Error(data?.error || 'Não foi possível registrar a execução da OP.');
analyticsCache.clear();
return data as ProductionOrderItem;
};
export const fetchProductionWorkstations = async (includeInactive = false): Promise<ProductionWorkstation[]> => {
const response = await authFetch(`/production-workstations${includeInactive ? '?includeInactive=true' : ''}`, { cache: 'no-store' });
if (!response.ok) throw new Error('Não foi possível carregar os postos de trabalho.');
return response.json() as Promise<ProductionWorkstation[]>;
};
export const createProductionWorkstation = async (payload: ProductionWorkstationPayload): Promise<ProductionWorkstation> => {
const response = await authFetch('/production-workstations', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
const data = await response.json().catch(() => null);
if (!response.ok) throw new Error(data?.error || 'Não foi possível criar o posto de trabalho.');
return data as ProductionWorkstation;
};
export const updateProductionWorkstation = async (id: number, payload: ProductionWorkstationPayload): Promise<ProductionWorkstation> => {
const response = await authFetch(`/production-workstations/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
const data = await response.json().catch(() => null);
if (!response.ok) throw new Error(data?.error || 'Não foi possível atualizar o posto de trabalho.');
return data as ProductionWorkstation;
};
export const addProductionOrderDefect = async (id: number, payload: ProductionOrderDefectPayload): Promise<ProductionOrderItem> => {
const response = await authFetch(`/production-orders/${id}/defects`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
const data = await response.json().catch(() => null);
if (!response.ok) throw new Error(data?.error || 'Não foi possível registrar o defeito.');
analyticsCache.clear();
return data as ProductionOrderItem;
};
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');
@@ -276,8 +397,9 @@ export const fetchCatalogSummary = async (): Promise<CatalogSummary> => {
} }
}; };
export const fetchOlistStatus = async (): Promise<OlistStatus> => { export const fetchOlistStatus = async (runsPage = 1, runsPageSize = 5): Promise<OlistStatus> => {
const response = await authFetch('/olist/status', { cache: 'no-store' }); const params = new URLSearchParams({ runsPage: String(runsPage), runsPageSize: String(runsPageSize) });
const response = await authFetch(`/olist/status?${params}`, { cache: 'no-store' });
const data = await response.json().catch(() => null); const data = await response.json().catch(() => null);
if (!response.ok) throw new Error(data?.error || 'Não foi possível consultar a integração Olist.'); if (!response.ok) throw new Error(data?.error || 'Não foi possível consultar a integração Olist.');
return data as OlistStatus; return data as OlistStatus;
@@ -314,13 +436,32 @@ export const fetchOlistRunDetails = async (runId: number, eventsPage = 1, produc
return data as OlistRunDetails; return data as OlistRunDetails;
}; };
export const fetchProductionOrderImportData = async (): Promise<ProductionOrderImportData> => { export const fetchProductionOrderImportData = async (runsPage = 1, runsPageSize = 5): Promise<ProductionOrderImportData> => {
const response = await authFetch('/olist/production-order-imports', { cache: 'no-store' }); const params = new URLSearchParams({ runsPage: String(runsPage), runsPageSize: String(runsPageSize) });
const response = await authFetch(`/olist/production-order-imports?${params}`, { cache: 'no-store' });
const data = await response.json().catch(() => null); const data = await response.json().catch(() => null);
if (!response.ok) throw new Error(data?.error || 'Não foi possível consultar as importações de OP.'); if (!response.ok) throw new Error(data?.error || 'Não foi possível consultar as importações de OP.');
return data as ProductionOrderImportData; return data as ProductionOrderImportData;
}; };
export const exportMissingProductionOrderDataCsv = async (): Promise<number> => {
const response = await authFetch('/olist/production-order-imports/pending-csv', { cache: 'no-store' });
const data = await response.json().catch(() => null);
if (!response.ok || !data?.csv || !data?.filename) {
throw new Error(data?.error || 'Não foi possível exportar as pendências das OPs.');
}
const url = URL.createObjectURL(new Blob([data.csv], { type: 'text/csv;charset=utf-8' }));
const anchor = document.createElement('a');
anchor.href = url;
anchor.download = data.filename;
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
URL.revokeObjectURL(url);
return Number(data.totalRows) || 0;
};
export const importFinalizedProductionOrderCsv = async (filename: string, csv: string): Promise<ProductionOrderImportData & { runId: number; totalRows: number; imported: number; updated: number; skipped: number; failed: number; issues: Array<{ line?: number; number?: string; message: string }> }> => { export const importFinalizedProductionOrderCsv = async (filename: string, csv: string): Promise<ProductionOrderImportData & { runId: number; totalRows: number; imported: number; updated: number; skipped: number; failed: number; issues: Array<{ line?: number; number?: string; message: string }> }> => {
const response = await authFetch('/olist/production-order-imports', { const response = await authFetch('/olist/production-order-imports', {
method: 'POST', method: 'POST',
@@ -481,6 +622,40 @@ export const fetchSupplySummary = async (): Promise<SupplySummary> => {
} }
}; };
export const fetchSupplySuppliers = async (): Promise<string[]> => {
const response = await authFetch('/supply/suppliers');
if (!response.ok) throw new Error('Não foi possível carregar os fornecedores cadastrados.');
return response.json() as Promise<string[]>;
};
export const fetchSupplyPurchaseOrders = async (): Promise<SupplyPurchaseOrder[]> => {
const response = await authFetch('/supply/purchase-orders');
if (!response.ok) throw new Error('Não foi possível carregar as ordens de compra.');
return response.json() as Promise<SupplyPurchaseOrder[]>;
};
export const createSupplyPurchaseOrder = async (payload: SupplyPurchaseOrderPayload): Promise<SupplyPurchaseOrder> => {
const response = await authFetch('/supply/purchase-orders', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
const data = await response.json().catch(() => null);
if (!response.ok) throw new Error(data?.error || 'Não foi possível criar a ordem de compra.');
return data as SupplyPurchaseOrder;
};
export const updateSupplyPurchaseOrderStatus = async (id: number, status: SupplyPurchaseOrderStatus): Promise<SupplyPurchaseOrder> => {
const response = await authFetch(`/supply/purchase-orders/${id}/status`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status })
});
const data = await response.json().catch(() => null);
if (!response.ok) throw new Error(data?.error || 'Não foi possível atualizar a ordem de compra.');
return data as SupplyPurchaseOrder;
};
export const saveSupplyMaterialDetails = async ( export const saveSupplyMaterialDetails = async (
materialKey: string, materialKey: string,
payload: SupplyMaterialDetailsPayload payload: SupplyMaterialDetailsPayload
@@ -571,6 +746,35 @@ export const deleteSupplyFabricPlan = async (id: number): Promise<void> => {
} }
}; };
export const fetchSupplyMaterialTransformations = async (): Promise<SupplyMaterialTransformation[]> => {
try {
const response = await authFetch('/supply/material-transformations');
if (!response.ok) return [];
return await response.json();
} catch (error) {
console.error('Fetch material transformations failed', error);
return [];
}
};
export const createSupplyMaterialTransformation = async (payload: SupplyMaterialTransformationPayload): Promise<SupplyMaterialTransformation> => {
const response = await authFetch('/supply/material-transformations', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload)
});
const data = await response.json().catch(() => null);
if (!response.ok) throw new Error(data?.error || 'Não foi possível salvar a transformação.');
return data as SupplyMaterialTransformation;
};
export const updateSupplyMaterialTransformationStatus = async (id: number, status: MaterialTransformationStatus): Promise<SupplyMaterialTransformation> => {
const response = await authFetch(`/supply/material-transformations/${id}/status`, {
method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ status })
});
const data = await response.json().catch(() => null);
if (!response.ok) throw new Error(data?.error || 'Não foi possível atualizar a transformação.');
return data as SupplyMaterialTransformation;
};
export const fetchSupplyPurchaseNeeds = async (): Promise<SupplyPurchaseNeed[]> => { export const fetchSupplyPurchaseNeeds = async (): Promise<SupplyPurchaseNeed[]> => {
try { try {
const response = await authFetch('/supply/purchase-needs'); const response = await authFetch('/supply/purchase-needs');
@@ -706,6 +910,23 @@ export const fetchProductComposition = async (productId: string): Promise<Produc
} }
}; };
export const saveManualProductComposition = async (productId: string, payload: ProductCompositionPayload): Promise<ProductComposition> => {
const response = await authFetch(`/analytics/products/${encodeURIComponent(productId)}/composition`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
const data = await response.json().catch(() => null) as { composition?: ProductComposition; error?: string } | null;
if (!response.ok || !data?.composition) throw new Error(data?.error || 'Não foi possível salvar a composição.');
return data.composition;
};
export const deleteManualProductComposition = async (productId: string): Promise<void> => {
const response = await authFetch(`/analytics/products/${encodeURIComponent(productId)}/composition`, { method: 'DELETE' });
const data = await response.json().catch(() => null) as { error?: string } | null;
if (!response.ok) throw new Error(data?.error || 'Não foi possível excluir a composição manual.');
};
export const fetchProductCompositions = async (): Promise<ProductComposition[]> => { export const fetchProductCompositions = async (): Promise<ProductComposition[]> => {
try { try {
const response = await authFetch('/analytics/product-compositions'); const response = await authFetch('/analytics/product-compositions');
@@ -932,6 +1153,18 @@ export const fetchUsers = async (): Promise<ManagedUser[]> => {
} }
}; };
export const fetchOperationalUsers = async (): Promise<OperationalUser[]> => {
try {
const response = await authFetch('/users/operational');
if (!response.ok) return [];
const data = await response.json() as { users?: OperationalUser[] };
return data.users || [];
} catch (error) {
console.error('Fetch operational users failed', error);
return [];
}
};
export const createUser = async (payload: { name: string; email: string; password?: string }): Promise<CreateUserResult> => { export const createUser = async (payload: { name: string; email: string; password?: string }): Promise<CreateUserResult> => {
const response = await authFetch('/users', { const response = await authFetch('/users', {
method: 'POST', method: 'POST',

View File

@@ -75,6 +75,17 @@
} }
@layer components { @layer components {
.yield-input {
appearance: textfield;
-moz-appearance: textfield;
}
.yield-input::-webkit-inner-spin-button,
.yield-input::-webkit-outer-spin-button {
margin: 0;
appearance: none;
}
.skeleton { .skeleton {
position: relative; position: relative;
overflow: hidden; overflow: hidden;

View File

@@ -1,13 +1,13 @@
import { useEffect, useMemo, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import { Link, useOutletContext, useSearchParams } from 'react-router-dom'; import { Link, useOutletContext, useSearchParams } from 'react-router-dom';
import { AlertTriangle, ArrowLeft, ClipboardList, Download, Eye, Layers3, Pencil, RotateCcw, Ruler, Save as SaveIcon, Scissors, Search, Settings2, X } from 'lucide-react'; import { AlertTriangle, ArrowLeft, ArrowRight, Check, ChevronDown, ClipboardList, Download, Eye, Layers3, Pencil, RotateCcw, Ruler, Save as SaveIcon, Scissors, Search, Settings2, UserRound, X } from 'lucide-react';
import DateRangePicker from '../components/DateRangePicker'; import DateRangePicker from '../components/DateRangePicker';
import PaginationControls from '../components/PaginationControls'; import PaginationControls from '../components/PaginationControls';
import ProductColorBadge from '../components/ProductColorBadge'; import ProductColorBadge from '../components/ProductColorBadge';
import RefreshStatus from '../components/RefreshStatus'; import RefreshStatus from '../components/RefreshStatus';
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 { createProductionOrders, exportToCSV, fetchCuttingSettings, fetchProductAnalytics, fetchProductCompositions, fetchProductionOrders, fetchProductionOrderImportData, fetchStock, saveCuttingSettings } from '../dataService'; import { createProductionOrders, exportToCSV, fetchCuttingSettings, fetchOperationalUsers, fetchProductAnalytics, fetchProductCompositions, fetchProductionOrders, fetchProductionOrderImportData, fetchStock, fetchSupplySummary, saveCuttingSettings } from '../dataService';
import type { CuttingSettings, DateRange, ProductAnalyticsItem, ProductComposition, ProductionOrderItem, ProductionYieldRecommendation, StockData } from '../types'; import type { CuttingSettings, DateRange, OperationalUser, ProductAnalyticsItem, ProductComposition, ProductionOrderItem, ProductionYieldRecommendation, StockData, SupplyLot } from '../types';
import { getPlanningStock } from '../planningStock'; import { getPlanningStock } from '../planningStock';
type CutFilter = 'need' | 'all' | 'issues' | 'covered'; type CutFilter = 'need' | 'all' | 'issues' | 'covered';
@@ -18,7 +18,10 @@ 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'; type CuttingView = 'plan' | 'families' | 'issues';
type PreviewStep = 'selection' | 'confirmation';
type PreviewReviewFilter = 'all' | 'needs_review' | 'complete';
type MaterialReadiness = { status: 'ready' | 'material_ready' | 'blocked' | 'missing'; blockers: string[] }; type MaterialReadiness = { status: 'ready' | 'material_ready' | 'blocked' | 'missing'; blockers: string[] };
type CutMaterialRequirement = { key: string; label: string; unit: string; quantity: number };
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];
@@ -43,7 +46,7 @@ const issueLabels: Record<CutIssue, string> = {
}; };
const issueHelp: Record<CutIssue, string> = { const issueHelp: Record<CutIssue, string> = {
missing_family_rule: 'Produto não caiu em BLCS, BLOS, BLMC ou BLPM.', missing_family_rule: 'Produto não caiu em BLCS, BLOS, BLMC, BLPM ou BLRM.',
missing_color: 'Nome do produto não tem uma cor clara para montar matriz de corte.', missing_color: 'Nome do produto não tem uma cor clara para montar matriz de corte.',
missing_size: 'Nome do produto não tem tamanho claro para montar matriz de corte.', missing_size: 'Nome do produto não tem tamanho claro para montar matriz de corte.',
missing_yield_rule: 'Família reconhecida, mas ainda falta cadastrar unidades por rolo.' missing_yield_rule: 'Família reconhecida, mas ainda falta cadastrar unidades por rolo.'
@@ -61,6 +64,7 @@ const familyStyles: Record<CutFamilyKey, string> = {
BLOS: 'border-sky-400/30 bg-sky-400/10 text-sky-300', BLOS: 'border-sky-400/30 bg-sky-400/10 text-sky-300',
BLMC: 'border-amber-400/30 bg-amber-400/10 text-amber-300', BLMC: 'border-amber-400/30 bg-amber-400/10 text-amber-300',
BLPM: 'border-purple-400/30 bg-purple-400/10 text-purple-300', BLPM: 'border-purple-400/30 bg-purple-400/10 text-purple-300',
BLRM: 'border-rose-400/30 bg-rose-400/10 text-rose-300',
OUTROS: 'border-zinc-500/30 bg-zinc-500/10 text-zinc-300' OUTROS: 'border-zinc-500/30 bg-zinc-500/10 text-zinc-300'
}; };
@@ -141,6 +145,7 @@ const Cutting = () => {
const [productionOrders, setProductionOrders] = useState<ProductionOrderItem[]>([]); const [productionOrders, setProductionOrders] = useState<ProductionOrderItem[]>([]);
const [compositions, setCompositions] = useState<ProductComposition[]>([]); const [compositions, setCompositions] = useState<ProductComposition[]>([]);
const [materialStock, setMaterialStock] = useState<StockData[]>([]); const [materialStock, setMaterialStock] = useState<StockData[]>([]);
const [supplyLots, setSupplyLots] = useState<SupplyLot[]>([]);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [searchTerm, setSearchTerm] = useState(''); const [searchTerm, setSearchTerm] = useState('');
const [targetCoverageDays, setTargetCoverageDays] = useState(30); const [targetCoverageDays, setTargetCoverageDays] = useState(30);
@@ -152,6 +157,7 @@ const Cutting = () => {
const [settingsSection, setSettingsSection] = useState<SettingsSection>('rules'); const [settingsSection, setSettingsSection] = useState<SettingsSection>('rules');
const [cuttingSettings, setCuttingSettings] = useState<CuttingSettings>(loadCuttingSettings); const [cuttingSettings, setCuttingSettings] = useState<CuttingSettings>(loadCuttingSettings);
const [yieldRecommendations, setYieldRecommendations] = useState<ProductionYieldRecommendation[]>([]); const [yieldRecommendations, setYieldRecommendations] = useState<ProductionYieldRecommendation[]>([]);
const [hasLoadedYieldRecommendations, setHasLoadedYieldRecommendations] = useState(false);
const [saveStatus, setSaveStatus] = useState<SaveStatus>('idle'); const [saveStatus, setSaveStatus] = useState<SaveStatus>('idle');
const [hasUnsavedSettings, setHasUnsavedSettings] = useState(false); const [hasUnsavedSettings, setHasUnsavedSettings] = useState(false);
const [correctionIssueFilter, setCorrectionIssueFilter] = useState<CorrectionIssueFilter>('all'); const [correctionIssueFilter, setCorrectionIssueFilter] = useState<CorrectionIssueFilter>('all');
@@ -160,8 +166,20 @@ const Cutting = () => {
const [itemsPerPage, setItemsPerPage] = useState(10); const [itemsPerPage, setItemsPerPage] = useState(10);
const [isGeneratingOrders, setIsGeneratingOrders] = useState(false); const [isGeneratingOrders, setIsGeneratingOrders] = useState(false);
const [isOrderPreviewOpen, setIsOrderPreviewOpen] = useState(false); const [isOrderPreviewOpen, setIsOrderPreviewOpen] = useState(false);
const [previewStep, setPreviewStep] = useState<PreviewStep>('selection');
const [previewPage, setPreviewPage] = useState(1); const [previewPage, setPreviewPage] = useState(1);
const [previewItemsPerPage, setPreviewItemsPerPage] = useState(10); const [previewItemsPerPage, setPreviewItemsPerPage] = useState(10);
const [confirmationPage, setConfirmationPage] = useState(1);
const [confirmationItemsPerPage, setConfirmationItemsPerPage] = useState(10);
const [previewReviewFilter, setPreviewReviewFilter] = useState<PreviewReviewFilter>('all');
const [selectedPreviewRowIds, setSelectedPreviewRowIds] = useState<string[]>([]);
const [previewQuantityOverrides, setPreviewQuantityOverrides] = useState<Record<string, string>>({});
const [lotSelections, setLotSelections] = useState<Record<string, { lotId: string; quantity: string }>>({});
const [productionPlanForm, setProductionPlanForm] = useState({ name: '', responsibleUserId: null as number | null, responsibleName: '', expectedDate: '', notes: '' });
const [operationalUsers, setOperationalUsers] = useState<OperationalUser[]>([]);
const [isLoadingOperationalUsers, setIsLoadingOperationalUsers] = useState(false);
const [isResponsiblePickerOpen, setIsResponsiblePickerOpen] = useState(false);
const [isPlanDetailsOpen, setIsPlanDetailsOpen] = useState(false);
const [editingCutRow, setEditingCutRow] = useState<CutPlanSkuRow | null>(null); const [editingCutRow, setEditingCutRow] = useState<CutPlanSkuRow | null>(null);
const [quickCutEdit, setQuickCutEdit] = useState<CutProductOverride>({}); const [quickCutEdit, setQuickCutEdit] = useState<CutProductOverride>({});
const [isQuickCutEditSaving, setIsQuickCutEditSaving] = useState(false); const [isQuickCutEditSaving, setIsQuickCutEditSaving] = useState(false);
@@ -197,31 +215,41 @@ const Cutting = () => {
}, []); }, []);
useEffect(() => { useEffect(() => {
if (!isSettingsOpen || settingsSection !== 'rules' || hasLoadedYieldRecommendations) return;
let isMounted = true; let isMounted = true;
void fetchProductionOrderImportData().then(data => { void fetchProductionOrderImportData().then(data => {
if (isMounted) setYieldRecommendations(data.recommendations); if (isMounted) {
setYieldRecommendations(data.recommendations);
setHasLoadedYieldRecommendations(true);
}
}).catch(() => { }).catch(() => {
if (isMounted) setYieldRecommendations([]); if (isMounted) {
setYieldRecommendations([]);
setHasLoadedYieldRecommendations(true);
}
}); });
return () => { isMounted = false; }; return () => { isMounted = false; };
}, []); }, [hasLoadedYieldRecommendations, isSettingsOpen, settingsSection]);
useEffect(() => { useEffect(() => {
let isMounted = true; let isMounted = true;
const loadProducts = async () => { const loadProducts = async () => {
setIsLoading(true); setIsLoading(true);
const [productData, productionOrderData, compositionData, stockData] = await Promise.all([ const [productData, productionOrderData, compositionData, stockData, supplySummary] = await Promise.all([
fetchProductAnalytics(dateRange), fetchProductAnalytics(dateRange),
fetchProductionOrders(allProductionOrdersRange), fetchProductionOrders(allProductionOrdersRange, { activeOnly: true }),
fetchProductCompositions(), fetchProductCompositions(),
fetchStock() fetchStock(),
fetchSupplySummary()
]); ]);
if (isMounted) { if (isMounted) {
setProducts(productData); setProducts(productData);
setProductionOrders(productionOrderData.orders); setProductionOrders(productionOrderData.orders);
setCompositions(compositionData); setCompositions(compositionData);
setMaterialStock(stockData); setMaterialStock(stockData);
setSupplyLots(supplySummary.lots);
setIsLoading(false); setIsLoading(false);
} }
}; };
@@ -243,7 +271,7 @@ const Cutting = () => {
[cuttingSettings, dateRange, openProductionByProductId, products, targetCoverageDays] [cuttingSettings, dateRange, openProductionByProductId, products, targetCoverageDays]
); );
const materialReadinessByProductId = useMemo(() => { const compositionsByProductId = useMemo(() => {
const compositionsById = new Map<string, ProductComposition>(); const compositionsById = new Map<string, ProductComposition>();
compositions.forEach(composition => { compositions.forEach(composition => {
[composition.finishedTinyProductId, composition.finishedProductSku] [composition.finishedTinyProductId, composition.finishedProductSku]
@@ -251,6 +279,10 @@ const Cutting = () => {
.filter(Boolean) .filter(Boolean)
.forEach(key => compositionsById.set(key, composition)); .forEach(key => compositionsById.set(key, composition));
}); });
return compositionsById;
}, [compositions]);
const materialReadinessByProductId = useMemo(() => {
const stockIndexByKey = new Map<string, number>(); const stockIndexByKey = new Map<string, number>();
materialStock.forEach((stock, index) => { materialStock.forEach((stock, index) => {
[stock.produto_id, stock.nome].map(normalizeMaterialKey).filter(Boolean).forEach(key => stockIndexByKey.set(key, index)); [stock.produto_id, stock.nome].map(normalizeMaterialKey).filter(Boolean).forEach(key => stockIndexByKey.set(key, index));
@@ -260,7 +292,7 @@ const Cutting = () => {
// Shared stock is allocated to the largest current requirement first. This // Shared stock is allocated to the largest current requirement first. This
// prevents every SKU from claiming the same material balance independently. // prevents every SKU from claiming the same material balance independently.
[...cutPlan.rows].sort((a, b) => b.suggestedCutQuantity - a.suggestedCutQuantity || a.name.localeCompare(b.name, 'pt-BR')).forEach(row => { [...cutPlan.rows].sort((a, b) => b.suggestedCutQuantity - a.suggestedCutQuantity || a.name.localeCompare(b.name, 'pt-BR')).forEach(row => {
const composition = compositionsById.get(normalizeMaterialKey(row.id)); const composition = compositionsByProductId.get(normalizeMaterialKey(row.id));
if (!composition?.components.length) { if (!composition?.components.length) {
// Products without an imported composition remain eligible for cutting. // Products without an imported composition remain eligible for cutting.
// They are excluded from material-demand calculations instead of // They are excluded from material-demand calculations instead of
@@ -297,7 +329,7 @@ const Cutting = () => {
readinessByProductId.set(row.id, { status: 'ready', blockers: [] }); readinessByProductId.set(row.id, { status: 'ready', blockers: [] });
}); });
return readinessByProductId; return readinessByProductId;
}, [compositions, cutPlan.rows, materialStock]); }, [compositionsByProductId, cutPlan.rows, materialStock]);
const issueSummaries = useMemo(() => { const issueSummaries = useMemo(() => {
const counts = new Map<CutIssue, number>(); const counts = new Map<CutIssue, number>();
@@ -373,17 +405,77 @@ const Cutting = () => {
}; };
}, [cutPlan.needRows, materialReadinessByProductId]); }, [cutPlan.needRows, materialReadinessByProductId]);
const getPreviewQuantity = useCallback((row: CutPlanSkuRow) => {
const override = Number((previewQuantityOverrides[row.id] || '').replace(',', '.'));
return Number.isFinite(override) && override > 0 ? Math.round(override) : row.suggestedCutQuantity;
}, [previewQuantityOverrides]);
const selectedPreviewRows = useMemo(() => {
const selectedIds = new Set(selectedPreviewRowIds);
return rowsAvailableForOrder.filter(row => selectedIds.has(row.id));
}, [rowsAvailableForOrder, selectedPreviewRowIds]);
const confirmationTotalPages = Math.ceil(selectedPreviewRows.length / confirmationItemsPerPage);
const safeConfirmationPage = Math.min(confirmationPage, confirmationTotalPages || 1);
const confirmationStartIndex = (safeConfirmationPage - 1) * confirmationItemsPerPage;
const paginatedConfirmationRows = selectedPreviewRows.slice(confirmationStartIndex, confirmationStartIndex + confirmationItemsPerPage);
const orderPreviewTotalUnits = useMemo(() => ( const orderPreviewTotalUnits = useMemo(() => (
rowsAvailableForOrder.reduce((total, row) => total + row.suggestedCutQuantity, 0) selectedPreviewRows.reduce((total, row) => total + getPreviewQuantity(row), 0)
), [rowsAvailableForOrder]); ), [getPreviewQuantity, selectedPreviewRows]);
const selectedResponsibleUser = useMemo(() => (
operationalUsers.find((user) => user.id === productionPlanForm.responsibleUserId && user.name === productionPlanForm.responsibleName) || null
), [operationalUsers, productionPlanForm.responsibleName, productionPlanForm.responsibleUserId]);
const cutPlanReference = useMemo(() => (
`Plano de corte ${formatDateKey(dateRange.start)} a ${formatDateKey(dateRange.end)} · ${targetCoverageDays} dias`
), [dateRange.end, dateRange.start, targetCoverageDays]);
const selectedMaterialRequirements = useMemo<CutMaterialRequirement[]>(() => {
const requirements = new Map<string, CutMaterialRequirement>();
selectedPreviewRows.forEach(row => {
const composition = compositionsByProductId.get(normalizeMaterialKey(row.id));
composition?.components
.filter(component => !isServiceComponent(component.componentName) && component.quantityPerUnit > 0)
.forEach(component => {
const key = normalizeMaterialKey(component.componentSku || component.componentTinyId || component.componentName);
if (!key) return;
const current = requirements.get(key) || {
key,
label: component.componentName,
unit: component.unit || 'UN',
quantity: 0
};
current.quantity += getPreviewQuantity(row) * component.quantityPerUnit;
requirements.set(key, current);
});
});
return Array.from(requirements.values()).sort((a, b) => a.label.localeCompare(b.label, 'pt-BR'));
}, [compositionsByProductId, getPreviewQuantity, selectedPreviewRows]);
const selectedLotReservations = useMemo(() => (
selectedMaterialRequirements.flatMap(requirement => {
const selection = lotSelections[requirement.key];
const lotId = Number(selection?.lotId);
const quantity = Number((selection?.quantity || '').replace(',', '.'));
return Number.isInteger(lotId) && Number.isFinite(quantity) && quantity > 0
? [{ lotId, materialKey: requirement.key, quantity, cutPlanReference }]
: [];
})
), [cutPlanReference, lotSelections, selectedMaterialRequirements]);
const previewRowsWithIssues = useMemo(() => ( const previewRowsWithIssues = useMemo(() => (
rowsAvailableForOrder.filter(row => row.issues.length).length selectedPreviewRows.filter(row => row.issues.length).length
), [rowsAvailableForOrder]); ), [selectedPreviewRows]);
const previewTotalPages = Math.ceil(rowsAvailableForOrder.length / previewItemsPerPage); const previewRows = useMemo(() => rowsAvailableForOrder.filter(row => {
if (previewReviewFilter === 'needs_review') return row.issues.length > 0;
if (previewReviewFilter === 'complete') return row.issues.length === 0;
return true;
}), [previewReviewFilter, rowsAvailableForOrder]);
const previewTotalPages = Math.ceil(previewRows.length / previewItemsPerPage);
const safePreviewPage = Math.min(previewPage, previewTotalPages || 1); const safePreviewPage = Math.min(previewPage, previewTotalPages || 1);
const previewStartIndex = (safePreviewPage - 1) * previewItemsPerPage; const previewStartIndex = (safePreviewPage - 1) * previewItemsPerPage;
const paginatedPreviewRows = rowsAvailableForOrder.slice(previewStartIndex, previewStartIndex + previewItemsPerPage); const paginatedPreviewRows = previewRows.slice(previewStartIndex, previewStartIndex + previewItemsPerPage);
const selectedPreviewRowIdSet = useMemo(() => new Set(selectedPreviewRowIds), [selectedPreviewRowIds]);
const areAllPreviewRowsSelected = rowsAvailableForOrder.length > 0 && selectedPreviewRows.length === rowsAvailableForOrder.length;
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);
@@ -609,12 +701,39 @@ const Cutting = () => {
setGenerationMessage(''); setGenerationMessage('');
setPreviewPage(1); setPreviewPage(1);
setConfirmationPage(1);
setPreviewReviewFilter('all');
setSelectedPreviewRowIds(rowsAvailableForOrder.map(row => row.id));
setPreviewQuantityOverrides({});
setLotSelections({});
setProductionPlanForm({ name: cutPlanReference, responsibleUserId: null, responsibleName: '', expectedDate: '', notes: '' });
setIsPlanDetailsOpen(false);
setIsResponsiblePickerOpen(false);
setPreviewStep('selection');
setIsLoadingOperationalUsers(true);
void fetchOperationalUsers().then(setOperationalUsers).finally(() => setIsLoadingOperationalUsers(false));
setIsOrderPreviewOpen(true); setIsOrderPreviewOpen(true);
}; };
const togglePreviewRow = (productId: string) => {
setSelectedPreviewRowIds(current => (
current.includes(productId)
? current.filter(id => id !== productId)
: [...current, productId]
));
};
const toggleAllPreviewRows = () => {
setSelectedPreviewRowIds(current => {
const currentIds = new Set(current);
const allRowsSelected = rowsAvailableForOrder.length > 0 && rowsAvailableForOrder.every(row => currentIds.has(row.id));
return allRowsSelected ? [] : rowsAvailableForOrder.map(row => row.id);
});
};
const generateProductionOrders = async () => { const generateProductionOrders = async () => {
if (!rowsAvailableForOrder.length) { if (!selectedPreviewRows.length) {
setGenerationMessage('Não há SKU com necessidade e materiais disponíveis no filtro atual.'); setGenerationMessage('Selecione pelo menos um SKU para gerar as ordens de produção.');
return; return;
} }
@@ -622,18 +741,28 @@ const Cutting = () => {
setGenerationMessage(''); setGenerationMessage('');
const issueDate = formatDateKey(new Date()); const issueDate = formatDateKey(new Date());
const orderReference = `Plano de corte ${formatDateKey(dateRange.start)} a ${formatDateKey(dateRange.end)} · ${targetCoverageDays} dias`; const orderReference = cutPlanReference;
try { try {
const result = await createProductionOrders(rowsAvailableForOrder.map(row => ({ const result = await createProductionOrders(selectedPreviewRows.map(row => ({
status: 'open', status: 'open',
orderReference, orderReference,
issueDate, issueDate,
productSku: row.id, productSku: row.id,
productDescription: row.name, productDescription: row.name,
quantity: row.suggestedCutQuantity, quantity: getPreviewQuantity(row),
unit: 'UN', unit: 'UN',
integrationStatus: 'Local', integrationStatus: 'Local',
components: (compositionsByProductId.get(normalizeMaterialKey(row.id))?.components || [])
.filter(component => component.quantityPerUnit > 0)
.map(component => ({
componentTinyId: component.componentTinyId,
componentSku: component.componentSku,
componentName: component.componentName,
quantityPerUnit: component.quantityPerUnit,
totalQuantity: getPreviewQuantity(row) * component.quantityPerUnit,
unit: component.unit
})),
markers: [ markers: [
{ label: row.family.materialLabel, color: '#38bdf8' }, { label: row.family.materialLabel, color: '#38bdf8' },
...(row.color ? [{ label: row.color, color: '#52DFA0' }] : []), ...(row.color ? [{ label: row.color, color: '#52DFA0' }] : []),
@@ -652,13 +781,22 @@ const Cutting = () => {
stock: row.stock, stock: row.stock,
openProductionQuantity: row.openProductionQuantity, openProductionQuantity: row.openProductionQuantity,
availableQuantity: row.availableQuantity, availableQuantity: row.availableQuantity,
manuallyAdjustedQuantity: getPreviewQuantity(row) !== row.suggestedCutQuantity,
issues: row.issues issues: row.issues
} }
}))); })), selectedLotReservations, {
name: productionPlanForm.name.trim() || cutPlanReference,
responsibleUserId: productionPlanForm.responsibleUserId,
responsibleName: productionPlanForm.responsibleName,
plannedStartDate: issueDate,
expectedDate: productionPlanForm.expectedDate || null,
notes: productionPlanForm.notes.trim(),
source: 'cut_plan'
});
const refreshedOrders = await fetchProductionOrders(allProductionOrdersRange, undefined, { force: true }); const refreshedOrders = await fetchProductionOrders(allProductionOrdersRange, { activeOnly: true }, { force: true });
setProductionOrders(refreshedOrders.orders); setProductionOrders(refreshedOrders.orders);
setGenerationMessage(`${result.created.length} OPs criadas${result.skipped.length ? ` · ${result.skipped.length} já existiam abertas` : ''}.`); setGenerationMessage(`${result.plan?.number || 'Plano'} criado com ${result.created.length} OPs${result.skipped.length ? ` · ${result.skipped.length} já existiam abertas` : ''}.`);
setIsOrderPreviewOpen(false); setIsOrderPreviewOpen(false);
} catch (error) { } catch (error) {
setGenerationMessage(error instanceof Error ? error.message : 'Não foi possível gerar as ordens de produção.'); setGenerationMessage(error instanceof Error ? error.message : 'Não foi possível gerar as ordens de produção.');
@@ -782,9 +920,11 @@ const Cutting = () => {
> >
<div className="flex flex-col gap-4 border-b border-dark-border p-5 md:flex-row md:items-start md:justify-between"> <div className="flex flex-col gap-4 border-b border-dark-border p-5 md:flex-row md:items-start md:justify-between">
<div> <div>
<h2 className="text-lg font-bold text-dark-text">Prévia das OPs</h2> <h2 className="text-lg font-bold text-dark-text">{previewStep === 'selection' ? 'Selecionar OPs' : 'Confirmar plano'}</h2>
<p className="mt-1 text-sm font-semibold text-dark-muted"> <p className="mt-1 text-sm font-semibold text-dark-muted">
Serão criadas ordens locais para as necessidades do filtro atual. {previewStep === 'selection'
? 'Escolha os SKUs que entrarão neste plano de produção.'
: 'Revise o plano e as reservas antes de criar as ordens locais.'}
</p> </p>
</div> </div>
<button <button
@@ -799,26 +939,149 @@ const Cutting = () => {
</button> </button>
</div> </div>
<div className="grid grid-cols-2 divide-x divide-y divide-dark-border border-b border-dark-border md:grid-cols-4 md:divide-y-0"> <div className="flex items-center gap-2 border-b border-dark-border bg-dark-input/30 px-5 py-3 text-xs font-bold">
{[ <span className={`inline-flex items-center gap-2 rounded-full px-3 py-1.5 ${previewStep === 'selection' ? 'bg-brand-primary/15 text-brand-primary' : 'text-dark-muted'}`}>
['OPs a criar', formatNumber(rowsAvailableForOrder.length), 'text-dark-text'], <span className={`inline-flex h-5 w-5 items-center justify-center rounded-full ${previewStep === 'selection' ? 'bg-brand-primary text-brand-contrast' : 'border border-dark-border'}`}>1</span>
['Unidades para cortar', `${formatNumber(orderPreviewTotalUnits)} un.`, 'text-brand-primary'], Selecionar OPs
['Cobertura alvo', `${formatNumber(targetCoverageDays)} dias`, 'text-dark-text'], </span>
['Revisar dados', formatNumber(previewRowsWithIssues), previewRowsWithIssues ? 'text-amber-300' : 'text-dark-text'] <ArrowRight className="h-3.5 w-3.5 text-dark-muted" />
].map(([label, value, tone]) => <div key={label} className="p-4"><p className="text-[10px] font-bold uppercase tracking-widest text-dark-muted">{label}</p><p className={`mt-1 text-xl font-bold ${tone}`}>{value}</p></div>)} <span className={`inline-flex items-center gap-2 rounded-full px-3 py-1.5 ${previewStep === 'confirmation' ? 'bg-brand-primary/15 text-brand-primary' : 'text-dark-muted'}`}>
<span className={`inline-flex h-5 w-5 items-center justify-center rounded-full ${previewStep === 'confirmation' ? 'bg-brand-primary text-brand-contrast' : 'border border-dark-border'}`}>2</span>
Confirmar plano
</span>
</div>
{previewStep === 'selection' && <div className="flex flex-col gap-3 border-b border-dark-border bg-dark-input/25 px-5 py-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex flex-wrap items-center gap-2 text-sm font-bold text-dark-text">
<span>{formatNumber(selectedPreviewRows.length)} selecionada{selectedPreviewRows.length === 1 ? '' : 's'}</span>
<span className="text-dark-muted">·</span>
<span className="text-brand-primary">{formatNumber(orderPreviewTotalUnits)} un.</span>
{previewRowsWithIssues > 0 && <><span className="text-dark-muted">·</span><span className="text-amber-300">{formatNumber(previewRowsWithIssues)} para revisar</span></>}
</div>
<span className="text-xs font-semibold text-dark-muted">Cobertura de {formatNumber(targetCoverageDays)} dias</span>
</div>}
{previewStep === 'confirmation' && <div className="border-b border-dark-border px-5 py-4">
<div className="flex flex-col gap-3 md:flex-row md:items-end">
<label className="block min-w-0 flex-1 text-xs font-bold text-dark-muted">Plano de produção
<input value={productionPlanForm.name} onChange={(event) => setProductionPlanForm(current => ({ ...current, name: event.target.value }))} className="mt-1 h-10 w-full rounded-lg border border-dark-border bg-dark-input px-3 text-sm font-semibold text-dark-text outline-none focus:border-brand-primary" />
</label>
<div className="relative shrink-0 text-xs font-bold text-dark-muted">
<span>Responsável</span>
<button
type="button"
onClick={() => setIsResponsiblePickerOpen(current => !current)}
className="mt-1 flex h-10 min-w-[220px] items-center justify-between gap-3 rounded-lg border border-dark-border bg-dark-input px-3 text-left text-sm font-semibold text-dark-text transition-colors hover:border-brand-primary cursor-pointer"
aria-expanded={isResponsiblePickerOpen}
>
<span className="flex min-w-0 items-center gap-2">
<UserRound className="h-4 w-4 shrink-0 text-brand-primary" />
<span className="truncate">{selectedResponsibleUser?.name || 'Selecionar responsável'}</span>
</span>
<ChevronDown className={`h-4 w-4 shrink-0 text-dark-muted transition-transform ${isResponsiblePickerOpen ? 'rotate-180' : ''}`} />
</button>
{isResponsiblePickerOpen && <div className="absolute right-0 z-20 mt-2 w-full min-w-[280px] overflow-hidden rounded-xl border border-dark-border bg-dark-card shadow-xl">
<div className="border-b border-dark-border px-3 py-2 text-[10px] font-bold uppercase tracking-wider text-dark-muted">Usuários ativos do Graphs</div>
<div className="max-h-56 overflow-y-auto p-1.5">
{isLoadingOperationalUsers ? <p className="px-2 py-3 text-xs font-semibold text-dark-muted">Carregando usuários...</p> : operationalUsers.length ? operationalUsers.map(user => (
<button key={user.id ?? 'super-admin'} type="button" onClick={() => { setProductionPlanForm(current => ({ ...current, responsibleUserId: user.id, responsibleName: user.name })); setIsResponsiblePickerOpen(false); }} className="flex w-full items-center justify-between gap-3 rounded-lg px-2.5 py-2 text-left text-sm font-semibold text-dark-text transition-colors hover:bg-dark-input cursor-pointer">
<span className="truncate">{user.name}</span>
{productionPlanForm.responsibleUserId === user.id && productionPlanForm.responsibleName === user.name && <Check className="h-4 w-4 shrink-0 text-brand-primary" />}
</button>
)) : <p className="px-2 py-3 text-xs font-semibold text-dark-muted">Nenhum usuário ativo encontrado.</p>}
</div>
{selectedResponsibleUser && <button type="button" onClick={() => { setProductionPlanForm(current => ({ ...current, responsibleUserId: null, responsibleName: '' })); setIsResponsiblePickerOpen(false); }} className="w-full border-t border-dark-border px-3 py-2 text-left text-xs font-bold text-dark-muted transition-colors hover:bg-dark-input hover:text-dark-text cursor-pointer">Remover responsável</button>}
</div>}
</div>
<button type="button" onClick={() => setIsPlanDetailsOpen(current => !current)} className="inline-flex h-10 shrink-0 items-center justify-center rounded-lg border border-dark-border bg-dark-card px-3 text-xs font-bold text-dark-muted transition-colors hover:border-brand-primary hover:text-dark-text cursor-pointer">
{isPlanDetailsOpen ? 'Ocultar detalhes' : 'Adicionar detalhes'}
</button>
</div>
{isPlanDetailsOpen && <div className="mt-3 grid grid-cols-1 gap-3 border-t border-dark-border pt-3 md:grid-cols-2">
<label className="text-xs font-bold text-dark-muted">Previsão de conclusão<input type="date" value={productionPlanForm.expectedDate} onChange={(event) => setProductionPlanForm(current => ({ ...current, expectedDate: event.target.value }))} className="mt-1 h-10 w-full rounded-lg border border-dark-border bg-dark-input px-3 text-sm font-semibold text-dark-text outline-none focus:border-brand-primary" /></label>
<label className="text-xs font-bold text-dark-muted">Observação<input value={productionPlanForm.notes} onChange={(event) => setProductionPlanForm(current => ({ ...current, notes: event.target.value }))} placeholder="Opcional" className="mt-1 h-10 w-full rounded-lg border border-dark-border bg-dark-input px-3 text-sm font-semibold text-dark-text outline-none placeholder:text-dark-muted focus:border-brand-primary" /></label>
</div>}
</div>}
{previewStep === 'confirmation' && <div className="min-h-0 flex-1 overflow-y-auto p-5">
<div className="rounded-xl border border-dark-border bg-dark-input/30 p-4">
<div className="flex flex-col gap-1 sm:flex-row sm:items-center sm:justify-between">
<div>
<h3 className="text-sm font-bold text-dark-text">OPs incluídas</h3>
<p className="mt-1 text-xs font-semibold text-dark-muted">{formatNumber(selectedPreviewRows.length)} SKU{selectedPreviewRows.length === 1 ? '' : 's'} · {formatNumber(orderPreviewTotalUnits)} un. para cortar</p>
</div>
{previewRowsWithIssues > 0 && <span className="text-xs font-bold text-amber-300">{formatNumber(previewRowsWithIssues)} para revisar</span>}
</div>
<div className="mt-3 divide-y divide-dark-border overflow-hidden rounded-lg border border-dark-border bg-dark-card">
{paginatedConfirmationRows.map(row => (
<div key={row.id} className="grid gap-3 px-3 py-3 lg:grid-cols-[minmax(0,1fr)_100px] lg:items-center">
<div className="min-w-0">
<p className="truncate text-sm font-bold text-dark-text" title={row.name}>{row.name}</p>
<div className="mt-1 flex flex-wrap items-center gap-1.5">
<span className="font-mono text-[10px] text-dark-muted">#{row.id}</span>
<span className={`rounded-full border px-2 py-0.5 text-[10px] font-bold ${familyStyles[row.family.key]}`}>{row.family.materialLabel}</span>
<ProductColorBadge label={row.color} className="max-w-[100px] px-2" />
<span className="text-[10px] font-semibold text-dark-muted">{row.size || 'Sem tamanho'}</span>
</div>
</div>
<label className="text-[10px] font-bold uppercase tracking-wider text-dark-muted">Cortar
<input inputMode="numeric" value={previewQuantityOverrides[row.id] ?? ''} onChange={(event) => setPreviewQuantityOverrides(current => ({ ...current, [row.id]: event.target.value }))} className="mt-1 h-9 w-full rounded-lg border border-dark-border bg-dark-input px-2 text-right text-sm font-bold text-brand-primary outline-none focus:border-brand-primary" placeholder={formatNumber(row.suggestedCutQuantity)} aria-label={`Quantidade planejada para ${row.name}`} />
</label>
</div>
))}
</div>
<PaginationControls
totalItems={selectedPreviewRows.length}
currentPage={safeConfirmationPage}
totalPages={confirmationTotalPages}
pageSize={confirmationItemsPerPage}
pageSizeOptions={[10, 20, 50]}
itemLabel="OPs"
pageSizeLabel="por página"
startIndex={confirmationStartIndex}
endIndex={Math.min(confirmationStartIndex + confirmationItemsPerPage, selectedPreviewRows.length)}
onPageChange={setConfirmationPage}
onPageSizeChange={(pageSize) => { setConfirmationItemsPerPage(pageSize); setConfirmationPage(1); }}
className="mt-3 border-t border-dark-border pt-3"
/>
<p className="mt-3 text-xs font-semibold text-dark-muted">A quantidade sugerida vem da cobertura. Ajustes manuais ficam marcados na OP, e o resultado real é confirmado durante a execução.</p>
</div>
</div>}
{previewStep === 'selection' && <>
<div className="flex flex-col gap-3 border-b border-dark-border bg-dark-input/40 px-4 py-3 sm:flex-row sm:items-center sm:justify-between">
<p className="text-xs font-semibold text-dark-muted">A seleção é mantida em todas as páginas. Use a caixa no cabeçalho para marcar ou desmarcar todos os SKUs aptos.</p>
<label className="flex shrink-0 items-center gap-2 text-xs font-bold text-dark-muted">
Revisão de dados
<select
value={previewReviewFilter}
onChange={(event) => {
setPreviewReviewFilter(event.target.value as PreviewReviewFilter);
setPreviewPage(1);
}}
className="h-9 rounded-lg border border-dark-border bg-dark-card px-2.5 text-xs font-bold text-dark-text outline-none transition-colors focus:border-brand-primary cursor-pointer"
aria-label="Filtrar produtos por revisão de dados"
>
<option value="all">Todos</option>
<option value="needs_review">Precisam de revisão</option>
<option value="complete">Sem revisão pendente</option>
</select>
</label>
</div> </div>
<div className="overflow-auto"> <div className="overflow-auto">
<table className="w-full min-w-[900px] table-fixed text-left text-sm"> <table className="w-full min-w-[950px] table-fixed text-left text-sm">
<colgroup> <colgroup>
<col className="w-[44%]" /> <col className="w-[5%]" />
<col className="w-[16%]" /> <col className="w-[41%]" />
<col className="w-[14%]" /> <col className="w-[15%]" />
<col className="w-[14%]" /> <col className="w-[13%]" />
<col className="w-[12%]" /> <col className="w-[13%]" />
<col className="w-[13%]" />
</colgroup> </colgroup>
<thead className="border-b border-dark-border bg-dark-header text-dark-muted"> <thead className="border-b border-dark-border bg-dark-header text-dark-muted">
<tr> <tr>
<th className="px-4 py-3 text-center"><input type="checkbox" checked={areAllPreviewRowsSelected} onChange={toggleAllPreviewRows} aria-label="Selecionar ou desmarcar todas as OPs aptas" className="h-4 w-4 accent-brand-primary cursor-pointer" /></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">Produto</th>
<th className="px-4 py-3 text-[10px] font-bold uppercase tracking-wider">Cortar agora</th> <th className="px-4 py-3 text-[10px] font-bold uppercase tracking-wider">Cortar agora</th>
<th className="px-4 py-3 text-[10px] font-bold uppercase tracking-wider">Demanda</th> <th className="px-4 py-3 text-[10px] font-bold uppercase tracking-wider">Demanda</th>
@@ -828,7 +1091,8 @@ const Cutting = () => {
</thead> </thead>
<tbody className="divide-y divide-dark-border"> <tbody className="divide-y divide-dark-border">
{paginatedPreviewRows.map(row => ( {paginatedPreviewRows.map(row => (
<tr key={row.id} className="hover:bg-dark-input/50"> <tr key={row.id} className={`hover:bg-dark-input/50 ${selectedPreviewRowIdSet.has(row.id) ? 'bg-brand-primary/[0.035]' : ''}`}>
<td className="px-4 py-3 text-center"><input type="checkbox" checked={selectedPreviewRowIdSet.has(row.id)} onChange={() => togglePreviewRow(row.id)} aria-label={`Selecionar ${row.name}`} className="h-4 w-4 accent-brand-primary cursor-pointer" /></td>
<td className="max-w-0 px-4 py-3"> <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="truncate font-bold text-dark-text" title={row.name}>{row.name}</div>
<div className="mt-1 flex flex-wrap items-center gap-1.5"> <div className="mt-1 flex flex-wrap items-center gap-1.5">
@@ -844,15 +1108,40 @@ const Cutting = () => {
<td className="px-4 py-3 font-bold whitespace-nowrap text-brand-primary">{formatNumber(row.suggestedCutQuantity)} un.</td> <td className="px-4 py-3 font-bold whitespace-nowrap text-brand-primary">{formatNumber(row.suggestedCutQuantity)} un.</td>
<td className="px-4 py-3 font-bold whitespace-nowrap text-dark-text">{formatNumber(row.projectedDemand, 1)} un.</td> <td className="px-4 py-3 font-bold whitespace-nowrap text-dark-text">{formatNumber(row.projectedDemand, 1)} un.</td>
<td className="px-4 py-3 whitespace-nowrap"><div className="font-bold text-dark-text">{formatNumber(row.availableQuantity)} un.</div>{row.openProductionQuantity ? <div className="mt-1 text-[10px] font-semibold text-dark-muted">{formatNumber(row.openProductionQuantity)} em OP</div> : null}</td> <td className="px-4 py-3 whitespace-nowrap"><div className="font-bold text-dark-text">{formatNumber(row.availableQuantity)} un.</div>{row.openProductionQuantity ? <div className="mt-1 text-[10px] font-semibold text-dark-muted">{formatNumber(row.openProductionQuantity)} em OP</div> : null}</td>
<td className="px-4 py-3">{renderIssueBadge(row)}</td> <td className="px-4 py-3">
<div className="flex items-center gap-3">
{renderIssueBadge(row)}
<button
type="button"
onClick={() => openQuickCutEdit(row)}
className="inline-flex h-7 w-7 shrink-0 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={`Revisar dados do SKU ${row.id}`}
aria-label={`Revisar dados do SKU ${row.id}`}
>
<Pencil className="h-3.5 w-3.5" />
</button>
</div>
</td>
</tr> </tr>
))} ))}
</tbody> </tbody>
</table> </table>
</div> </div>
</>}
<PaginationControls {previewStep === 'confirmation' && selectedMaterialRequirements.length > 0 && <section className="border-t border-dark-border bg-dark-input/30 p-4">
totalItems={rowsAvailableForOrder.length} <div className="flex flex-col gap-1 sm:flex-row sm:items-end sm:justify-between">
<div>
<h3 className="text-sm font-bold text-dark-text">Reservar lotes para este corte</h3>
<p className="mt-1 text-xs font-semibold text-dark-muted">A reserva é opcional quando o saldo vem do Tiny. Ao selecionar um lote, ela bloqueia somente o saldo disponível para outros planos; a baixa física continua na OP.</p>
</div>
<span className="text-xs font-bold text-brand-primary">{formatNumber(selectedLotReservations.length)} lote(s) reservado(s)</span>
</div>
{selectedMaterialRequirements.length ? <div className="mt-4 overflow-x-auto rounded-xl border border-dark-border bg-dark-card"><table className="w-full min-w-[760px] text-left text-xs"><thead className="bg-dark-header text-dark-muted"><tr><th className="px-3 py-2.5 font-bold">Material necessário</th><th className="px-3 py-2.5 text-right font-bold">Necessário</th><th className="px-3 py-2.5 font-bold">Lote reservado</th><th className="px-3 py-2.5 text-right font-bold">Quantidade reservada</th></tr></thead><tbody className="divide-y divide-dark-border">{selectedMaterialRequirements.map(requirement => { const selection = lotSelections[requirement.key] || { lotId: '', quantity: '' }; const lots = supplyLots.filter(lot => { const lotKey = normalizeMaterialKey(lot.product); const materialMatch = lotKey === requirement.key || lotKey === normalizeMaterialKey(requirement.label); return lot.availableQuantity > 0 && materialMatch && lot.unit.toLowerCase() === requirement.unit.toLowerCase(); }); return <tr key={requirement.key}><td className="px-3 py-3"><p className="font-bold text-dark-text">{requirement.label}</p><p className="mt-0.5 font-mono text-[10px] text-dark-muted">{requirement.key}</p></td><td className="px-3 py-3 text-right font-bold text-dark-text">{formatNumber(requirement.quantity, 4)} {requirement.unit}</td><td className="px-3 py-3"><select value={selection.lotId} onChange={(event) => setLotSelections(current => ({ ...current, [requirement.key]: { lotId: event.target.value, quantity: current[requirement.key]?.quantity || '' } }))} className="h-9 w-full min-w-[230px] rounded-lg border border-dark-border bg-dark-input px-2 text-xs font-semibold text-dark-text outline-none focus:border-brand-primary"><option value="">{lots.length ? 'Não reservar agora' : 'Sem lote compatível'}</option>{lots.map(lot => <option key={lot.id} value={lot.id}>#{lot.id} · {lot.product} · disp. {formatNumber(lot.availableQuantity, 4)} {lot.unit}</option>)}</select></td><td className="px-3 py-3"><input inputMode="decimal" value={selection.quantity} onChange={(event) => setLotSelections(current => ({ ...current, [requirement.key]: { lotId: current[requirement.key]?.lotId || '', quantity: event.target.value } }))} disabled={!selection.lotId} className="h-9 w-32 rounded-lg border border-dark-border bg-dark-input px-2 text-right text-xs font-semibold text-dark-text outline-none placeholder:text-dark-muted focus:border-brand-primary disabled:cursor-not-allowed disabled:opacity-50" placeholder={formatNumber(requirement.quantity, 4)} /></td></tr>; })}</tbody></table></div> : <p className="mt-3 rounded-xl border border-dark-border bg-dark-card px-3 py-3 text-xs font-semibold text-dark-muted">Os SKUs selecionados não possuem componentes de material sincronizados. Eles podem seguir para OP, mas não geram reserva.</p>}
</section>}
{previewStep === 'selection' && <PaginationControls
totalItems={previewRows.length}
currentPage={safePreviewPage} currentPage={safePreviewPage}
totalPages={previewTotalPages} totalPages={previewTotalPages}
pageSize={previewItemsPerPage} pageSize={previewItemsPerPage}
@@ -860,33 +1149,36 @@ const Cutting = () => {
itemLabel="OPs" itemLabel="OPs"
pageSizeLabel="por página" pageSizeLabel="por página"
startIndex={previewStartIndex} startIndex={previewStartIndex}
endIndex={Math.min(previewStartIndex + previewItemsPerPage, rowsAvailableForOrder.length)} endIndex={Math.min(previewStartIndex + previewItemsPerPage, previewRows.length)}
onPageChange={setPreviewPage} onPageChange={setPreviewPage}
onPageSizeChange={(pageSize) => { setPreviewItemsPerPage(pageSize); setPreviewPage(1); }} onPageSizeChange={(pageSize) => { setPreviewItemsPerPage(pageSize); setPreviewPage(1); }}
className="border-t border-dark-border px-4 py-3" className="border-t border-dark-border px-4 py-3"
/> />}
<div className="flex flex-col gap-3 border-t border-dark-border p-4 sm:flex-row sm:items-center sm:justify-between"> <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"> <p className="text-xs font-semibold text-dark-muted">{previewStep === 'selection'
OPs abertas já existentes para o mesmo plano e SKU serão ignoradas. ? 'Você poderá revisar o plano e reservar lotes na próxima etapa.'
: 'OPs abertas já existentes para o mesmo plano e SKU serão ignoradas.'}
</p> </p>
<div className="flex flex-col gap-2 sm:flex-row sm:justify-end"> <div className="flex flex-col gap-2 sm:flex-row sm:justify-end">
<button <button
type="button" type="button"
onClick={() => setIsOrderPreviewOpen(false)} onClick={() => previewStep === 'selection' ? setIsOrderPreviewOpen(false) : setPreviewStep('selection')}
disabled={isGeneratingOrders} 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" 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 {previewStep === 'selection' ? 'Cancelar' : 'Voltar'}
</button> </button>
<button <button
type="button" type="button"
onClick={() => void generateProductionOrders()} onClick={() => previewStep === 'selection' ? (setConfirmationPage(1), setPreviewStep('confirmation')) : void generateProductionOrders()}
disabled={isGeneratingOrders} disabled={isGeneratingOrders || !selectedPreviewRows.length}
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" 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" /> {previewStep === 'selection' ? <ArrowRight className="h-4 w-4" /> : <ClipboardList className="h-4 w-4" />}
{isGeneratingOrders ? 'Gerando OPs' : 'Confirmar geração'} {previewStep === 'selection'
? `Continuar com ${formatNumber(selectedPreviewRows.length)} ${selectedPreviewRows.length === 1 ? 'OP' : 'OPs'}`
: isGeneratingOrders ? 'Gerando OPs' : `Confirmar ${formatNumber(selectedPreviewRows.length)} ${selectedPreviewRows.length === 1 ? 'OP' : 'OPs'}`}
</button> </button>
</div> </div>
</div> </div>
@@ -929,14 +1221,14 @@ const Cutting = () => {
{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 min-h-dvh items-center justify-center overflow-y-auto bg-zinc-950/80 p-4 backdrop-blur-md dark:bg-black/75 sm:p-6"
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
aria-label="Regras de corte" aria-label="Regras de corte"
onClick={() => setIsSettingsOpen(false)} onClick={() => setIsSettingsOpen(false)}
> >
<div <div
className="h-dvh w-full max-w-6xl space-y-4 overflow-y-auto border-l border-dark-border bg-dark-card p-5 shadow-2xl" className="max-h-[calc(100dvh-2rem)] w-full max-w-6xl space-y-4 overflow-y-auto rounded-2xl border border-dark-border bg-dark-card p-5 shadow-2xl sm:max-h-[calc(100dvh-3rem)] sm:p-6"
onClick={(event) => event.stopPropagation()} onClick={(event) => event.stopPropagation()}
> >
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between"> <div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
@@ -1005,7 +1297,7 @@ const Cutting = () => {
</div> </div>
{settingsSection === 'rules' && ( {settingsSection === 'rules' && (
<div className="grid grid-cols-1 gap-3 md:grid-cols-4"> <div className="grid grid-cols-1 gap-3 md:grid-cols-5">
{CUT_FAMILY_RULES.map(rule => ( {CUT_FAMILY_RULES.map(rule => (
<label key={rule.key} className="rounded-xl border border-dark-border bg-dark-input p-3"> <label key={rule.key} className="rounded-xl border border-dark-border bg-dark-input p-3">
<span className="mb-2 flex items-center justify-between gap-2"> <span className="mb-2 flex items-center justify-between gap-2">
@@ -1019,7 +1311,7 @@ const Cutting = () => {
value={cuttingSettings.familyYields[rule.key] || ''} value={cuttingSettings.familyYields[rule.key] || ''}
onChange={(event) => updateFamilyYield(rule.key, event.target.value)} onChange={(event) => updateFamilyYield(rule.key, event.target.value)}
placeholder="un./rolo" placeholder="un./rolo"
className="h-10 w-full rounded-lg border border-dark-border bg-dark-card px-3 text-sm font-bold text-dark-text outline-none transition-colors focus:border-brand-primary" className="yield-input h-10 w-full rounded-lg border border-dark-border bg-dark-card px-3 text-sm font-bold text-dark-text outline-none transition-colors focus:border-brand-primary"
/> />
{yieldRecommendations.find(item => item.familyKey === rule.key)?.recommendedUnitsPerRoll && (() => { {yieldRecommendations.find(item => item.familyKey === rule.key)?.recommendedUnitsPerRoll && (() => {
const recommendation = yieldRecommendations.find(item => item.familyKey === rule.key)!; const recommendation = yieldRecommendations.find(item => item.familyKey === rule.key)!;

View File

@@ -1,6 +1,6 @@
import { useCallback, useEffect, useMemo, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import { AlertTriangle, CheckCircle2, CloudDownload, Database, FileUp, Link2, Loader2, RefreshCw, ScrollText, Square, Timer, X, XCircle } from 'lucide-react'; import { AlertTriangle, CheckCircle2, ChevronLeft, ChevronRight, CloudDownload, Database, Download, FileUp, Link2, Loader2, RefreshCw, ScrollText, Square, Timer, X, XCircle } from 'lucide-react';
import { fetchOlistRunDetails, fetchOlistStatus, fetchProductionOrderImportData, getOlistAuthorizationUrl, importFinalizedProductionOrderCsv, startOlistCompositionSync, stopOlistCompositionSync } from '../dataService'; import { exportMissingProductionOrderDataCsv, fetchOlistRunDetails, fetchOlistStatus, fetchProductionOrderImportData, getOlistAuthorizationUrl, importFinalizedProductionOrderCsv, startOlistCompositionSync, stopOlistCompositionSync } from '../dataService';
import type { OlistRunDetails, OlistStatus, OlistSyncRun, ProductionOrderImportData } from '../types'; import type { OlistRunDetails, OlistStatus, OlistSyncRun, ProductionOrderImportData } from '../types';
const formatDateTime = (value: string | null) => { const formatDateTime = (value: string | null) => {
@@ -38,6 +38,42 @@ const runStatusLabel = (run: OlistSyncRun) => (
: 'Concluída' : 'Concluída'
); );
const yieldFamilyLabels: Record<string, string> = {
BLCS: 'Camiseta regular',
BLOS: 'Oversize',
BLMC: 'Moletom canguru',
BLPM: 'Camiseta Pima',
BLRM: 'Regata'
};
const PAGE_SIZE = 5;
const PaginationControls = ({
page,
pageSize,
total,
onChange,
label
}: {
page: number;
pageSize: number;
total: number;
onChange: (page: number) => void;
label: string;
}) => {
const totalPages = Math.max(1, Math.ceil(total / pageSize));
const start = total ? ((page - 1) * pageSize) + 1 : 0;
const end = Math.min(page * pageSize, total);
return <div className="flex flex-wrap items-center justify-between gap-3 border-t border-dark-border px-4 py-3">
<span className="text-xs font-semibold text-dark-muted">{total ? `${label} ${start}-${end} de ${total.toLocaleString('pt-BR')}` : `Nenhum ${label.toLowerCase()}`}</span>
<div className="flex items-center gap-2">
<button type="button" onClick={() => onChange(page - 1)} disabled={page <= 1} aria-label="Página anterior" title="Página anterior" className="inline-flex h-8 w-8 items-center justify-center rounded-lg border border-dark-border text-dark-muted transition-colors hover:border-brand-primary hover:text-dark-text disabled:cursor-not-allowed disabled:opacity-40 cursor-pointer"><ChevronLeft className="h-4 w-4" /></button>
<span className="min-w-16 text-center text-xs font-bold text-dark-muted">Página {page} de {totalPages}</span>
<button type="button" onClick={() => onChange(page + 1)} disabled={page >= totalPages} aria-label="Próxima página" title="Próxima página" className="inline-flex h-8 w-8 items-center justify-center rounded-lg border border-dark-border text-dark-muted transition-colors hover:border-brand-primary hover:text-dark-text disabled:cursor-not-allowed disabled:opacity-40 cursor-pointer"><ChevronRight className="h-4 w-4" /></button>
</div>
</div>;
};
const OlistSync = () => { const OlistSync = () => {
const [status, setStatus] = useState<OlistStatus | null>(null); const [status, setStatus] = useState<OlistStatus | null>(null);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
@@ -50,19 +86,24 @@ const OlistSync = () => {
const [isDetailsLoading, setIsDetailsLoading] = useState(false); const [isDetailsLoading, setIsDetailsLoading] = useState(false);
const [importData, setImportData] = useState<ProductionOrderImportData | null>(null); const [importData, setImportData] = useState<ProductionOrderImportData | null>(null);
const [isImportingOrders, setIsImportingOrders] = useState(false); const [isImportingOrders, setIsImportingOrders] = useState(false);
const [isExportingPendingOrders, setIsExportingPendingOrders] = useState(false);
const [importMessage, setImportMessage] = useState(''); const [importMessage, setImportMessage] = useState('');
const [syncRunsPage, setSyncRunsPage] = useState(1);
const [importRunsPage, setImportRunsPage] = useState(1);
const [supplierReferencesPage, setSupplierReferencesPage] = useState(1);
const [importView, setImportView] = useState<'suppliers' | 'files'>('suppliers');
const loadStatus = useCallback(async (initial = false) => { const loadStatus = useCallback(async (initial = false) => {
if (initial) setIsLoading(true); if (initial) setIsLoading(true);
try { try {
setStatus(await fetchOlistStatus()); setStatus(await fetchOlistStatus(syncRunsPage, PAGE_SIZE));
setError(''); setError('');
} catch (loadError) { } catch (loadError) {
setError(loadError instanceof Error ? loadError.message : 'Não foi possível consultar a Olist.'); setError(loadError instanceof Error ? loadError.message : 'Não foi possível consultar a Olist.');
} finally { } finally {
if (initial) setIsLoading(false); if (initial) setIsLoading(false);
} }
}, []); }, [syncRunsPage]);
useEffect(() => { useEffect(() => {
const loadTimeout = window.setTimeout(() => void loadStatus(true), 0); const loadTimeout = window.setTimeout(() => void loadStatus(true), 0);
@@ -88,11 +129,11 @@ const OlistSync = () => {
const loadImportData = useCallback(async () => { const loadImportData = useCallback(async () => {
try { try {
setImportData(await fetchProductionOrderImportData()); setImportData(await fetchProductionOrderImportData(importRunsPage, PAGE_SIZE));
} catch (loadError) { } catch (loadError) {
setError(loadError instanceof Error ? loadError.message : 'Não foi possível consultar as importações de OP.'); setError(loadError instanceof Error ? loadError.message : 'Não foi possível consultar as importações de OP.');
} }
}, []); }, [importRunsPage]);
useEffect(() => { useEffect(() => {
const timeoutId = window.setTimeout(() => void loadImportData(), 0); const timeoutId = window.setTimeout(() => void loadImportData(), 0);
@@ -105,13 +146,18 @@ const OlistSync = () => {
return () => window.clearInterval(intervalId); return () => window.clearInterval(intervalId);
}, [selectedRunId, runDetails?.run.status, detailsPages, loadRunDetails]); }, [selectedRunId, runDetails?.run.status, detailsPages, loadRunDetails]);
const latestRun = status?.runs[0] || null; const latestRun = status?.latestRun || null;
const tokenExpired = Boolean(status?.tokenExpiresAt && new Date(status.tokenExpiresAt).getTime() <= now); const tokenExpired = Boolean(status?.tokenExpiresAt && new Date(status.tokenExpiresAt).getTime() <= now);
const completionPercent = useMemo(() => { const completionPercent = useMemo(() => {
const cache = status?.productCache; const cache = status?.productCache;
if (!cache?.total) return 0; if (!cache?.total) return 0;
return Math.round((cache.checked / cache.total) * 100); return Math.round((cache.checked / cache.total) * 100);
}, [status?.productCache]); }, [status?.productCache]);
const supplierReferences = useMemo(() => (importData?.supplierRecommendations || [])
.filter(item => item.recommendedUnitsPerRoll)
.sort((left, right) => left.familyKey.localeCompare(right.familyKey) || left.supplier.localeCompare(right.supplier)), [importData?.supplierRecommendations]);
const supplierReferencesTotalPages = Math.max(1, Math.ceil(supplierReferences.length / PAGE_SIZE));
const visibleSupplierReferences = supplierReferences.slice((Math.min(supplierReferencesPage, supplierReferencesTotalPages) - 1) * PAGE_SIZE, Math.min(supplierReferencesPage, supplierReferencesTotalPages) * PAGE_SIZE);
const connect = async () => { const connect = async () => {
setIsActionLoading(true); setIsActionLoading(true);
@@ -126,7 +172,7 @@ const OlistSync = () => {
const startSync = async () => { const startSync = async () => {
setIsActionLoading(true); setIsActionLoading(true);
try { try {
await startOlistCompositionSync(!status?.runs.some(run => run.status === 'completed')); await startOlistCompositionSync(!status?.hasCompletedRun);
await loadStatus(); await loadStatus();
} catch (actionError) { } catch (actionError) {
setError(actionError instanceof Error ? actionError.message : 'Não foi possível iniciar a sincronização.'); setError(actionError instanceof Error ? actionError.message : 'Não foi possível iniciar a sincronização.');
@@ -158,7 +204,15 @@ const OlistSync = () => {
setImportMessage(''); setImportMessage('');
try { try {
const result = await importFinalizedProductionOrderCsv(file.name, await file.text()); const result = await importFinalizedProductionOrderCsv(file.name, await file.text());
setImportData({ runs: result.runs, recommendations: result.recommendations }); setImportRunsPage(1);
setImportData({
runs: result.runs,
runsTotal: result.runsTotal,
runsPage: result.runsPage,
runsPageSize: result.runsPageSize,
recommendations: result.recommendations,
supplierRecommendations: result.supplierRecommendations
});
setImportMessage(`${result.imported.toLocaleString('pt-BR')} novas · ${result.updated.toLocaleString('pt-BR')} atualizadas · ${result.skipped.toLocaleString('pt-BR')} ignoradas.`); setImportMessage(`${result.imported.toLocaleString('pt-BR')} novas · ${result.updated.toLocaleString('pt-BR')} atualizadas · ${result.skipped.toLocaleString('pt-BR')} ignoradas.`);
} catch (actionError) { } catch (actionError) {
setError(actionError instanceof Error ? actionError.message : 'Não foi possível importar as OPs finalizadas.'); setError(actionError instanceof Error ? actionError.message : 'Não foi possível importar as OPs finalizadas.');
@@ -167,6 +221,19 @@ const OlistSync = () => {
} }
}; };
const exportPendingOrders = async () => {
setIsExportingPendingOrders(true);
setImportMessage('');
try {
const totalRows = await exportMissingProductionOrderDataCsv();
setImportMessage(totalRows ? `${totalRows.toLocaleString('pt-BR')} OPs com pendências exportadas para completar.` : 'Nenhuma OP finalizada possui pendências de dados.');
} catch (actionError) {
setError(actionError instanceof Error ? actionError.message : 'Não foi possível exportar as pendências das OPs.');
} finally {
setIsExportingPendingOrders(false);
}
};
const openRunDetails = (runId: number) => { const openRunDetails = (runId: number) => {
const initialPages = { events: 1, products: 1 }; const initialPages = { events: 1, products: 1 };
setDetailsPages(initialPages); setDetailsPages(initialPages);
@@ -189,6 +256,9 @@ const OlistSync = () => {
<button type="button" onClick={() => void loadStatus()} className="inline-flex h-10 items-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"> <button type="button" onClick={() => void loadStatus()} className="inline-flex h-10 items-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" /> Atualizar <RefreshCw className="h-4 w-4" /> Atualizar
</button> </button>
<button type="button" onClick={() => void exportPendingOrders()} disabled={isExportingPendingOrders} className="inline-flex h-10 items-center gap-2 rounded-xl border border-dark-border bg-dark-card px-4 text-sm font-bold text-dark-text transition-colors hover:border-brand-primary disabled:cursor-not-allowed disabled:opacity-60 cursor-pointer">
{isExportingPendingOrders ? <Loader2 className="h-4 w-4 animate-spin" /> : <Download className="h-4 w-4 text-brand-primary" />} Exportar pendências
</button>
<label className={`inline-flex h-10 items-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 ${isImportingOrders ? 'cursor-wait opacity-60' : 'cursor-pointer'}`}> <label className={`inline-flex h-10 items-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 ${isImportingOrders ? 'cursor-wait opacity-60' : 'cursor-pointer'}`}>
{isImportingOrders ? <Loader2 className="h-4 w-4 animate-spin" /> : <FileUp className="h-4 w-4 text-brand-primary" />} Importar OPs finalizadas {isImportingOrders ? <Loader2 className="h-4 w-4 animate-spin" /> : <FileUp className="h-4 w-4 text-brand-primary" />} Importar OPs finalizadas
<input type="file" accept=".csv,text/csv" className="sr-only" disabled={isImportingOrders} onChange={(event) => { void importOrders(event.target.files?.[0] || null); event.currentTarget.value = ''; }} /> <input type="file" accept=".csv,text/csv" className="sr-only" disabled={isImportingOrders} onChange={(event) => { void importOrders(event.target.files?.[0] || null); event.currentTarget.value = ''; }} />
@@ -216,7 +286,7 @@ const OlistSync = () => {
</div> </div>
{error && <div className="rounded-2xl border border-red-400/30 bg-red-400/10 px-4 py-3 text-sm font-bold text-red-300">{error}</div>} {error && <div className="rounded-2xl border border-red-400/30 bg-red-400/10 px-4 py-3 text-sm font-bold text-red-300">{error}</div>}
{importMessage && <div className="rounded-2xl border border-emerald-400/30 bg-emerald-400/10 px-4 py-3 text-sm font-bold text-emerald-300">Importação concluída: {importMessage}</div>} {importMessage && <div className="rounded-2xl border border-emerald-400/30 bg-emerald-400/10 px-4 py-3 text-sm font-bold text-emerald-300">{importMessage.startsWith('Nenhuma') || importMessage.includes('exportadas') ? 'Exportação pronta: ' : 'Importação concluída: '}{importMessage}</div>}
{!status?.configured && <div className="rounded-2xl border border-amber-400/30 bg-amber-400/10 px-4 py-3 text-sm font-bold text-amber-200">Configure as credenciais Olist V3 no ambiente antes de conectar.</div>} {!status?.configured && <div className="rounded-2xl border border-amber-400/30 bg-amber-400/10 px-4 py-3 text-sm font-bold text-amber-200">Configure as credenciais Olist V3 no ambiente antes de conectar.</div>}
<div className="grid grid-cols-1 gap-4 md:grid-cols-3"> <div className="grid grid-cols-1 gap-4 md:grid-cols-3">
@@ -265,41 +335,62 @@ const OlistSync = () => {
</div> </div>
)} )}
<div className="overflow-hidden rounded-2xl border border-dark-border bg-dark-card">
<div className="border-b border-dark-border p-5"><h2 className="text-sm font-bold text-dark-text">Histórico de sincronizações</h2><p className="mt-1 text-xs font-semibold text-dark-muted">Execuções automáticas e manuais da sincronização de composições.</p></div>
<div className="divide-y divide-dark-border">
{!status?.runs.length && <div className="p-8 text-center text-sm font-semibold text-dark-muted">Nenhuma sincronização iniciada.</div>}
{status?.runs.map(run => <div key={run.id} className="flex flex-col gap-3 p-4 md:flex-row md:items-center md:justify-between"><div><p className="text-sm font-bold text-dark-text">{run.mode === 'full' ? 'Carga completa' : 'Atualização incremental'} · {run.trigger === 'scheduled' ? 'Automática' : 'Manual'}</p><p className="mt-1 text-xs font-semibold text-dark-muted">{formatDateTime(run.startedAt)} · duração {formatDuration(run.startedAt, run.status === 'running' ? null : run.completedAt, now)}</p></div><div className="flex flex-wrap items-center gap-3 text-xs font-bold text-dark-muted"><span>{run.imported} comp.</span><span>{run.referenceCount} refs.</span><span className={run.failed ? 'text-red-300' : ''}>{run.failed} falhas</span><span className={`rounded-full border px-2.5 py-1 ${runTone(run)}`}>{runStatusLabel(run)}</span><button type="button" onClick={() => openRunDetails(run.id)} title="Ver logs e produtos afetados" aria-label="Ver logs e produtos afetados" className="inline-flex h-8 w-8 items-center justify-center rounded-lg border border-dark-border text-brand-primary transition-colors hover:border-brand-primary cursor-pointer"><ScrollText className="h-4 w-4" /></button></div></div>)}
</div>
{!!status && <PaginationControls page={status.runsPage} pageSize={status.runsPageSize} total={status.runsTotal} label="Execuções" onChange={setSyncRunsPage} />}
</div>
<section className="overflow-hidden rounded-2xl border border-dark-border bg-dark-card"> <section className="overflow-hidden rounded-2xl border border-dark-border bg-dark-card">
<div className="flex flex-col gap-2 border-b border-dark-border p-5 sm:flex-row sm:items-center sm:justify-between"> <div className="flex flex-col gap-2 border-b border-dark-border p-5 sm:flex-row sm:items-center sm:justify-between">
<div> <div>
<h2 className="text-sm font-bold text-dark-text">OPs finalizadas importadas</h2> <h2 className="text-sm font-bold text-dark-text">OPs finalizadas importadas</h2>
<p className="mt-1 text-xs font-semibold text-dark-muted">CSV histórico para leitura de observações e recomendação de rendimento. Reimportar o mesmo arquivo atualiza as OPs sem duplicá-las.</p> <p className="mt-1 text-xs font-semibold text-dark-muted">Histórico CSV para recomendações de rendimento. Reimportar o mesmo arquivo atualiza as OPs sem duplicá-las.</p>
</div> </div>
<button type="button" onClick={() => void loadImportData()} title="Atualizar importações" aria-label="Atualizar importações" className="inline-flex h-8 w-8 items-center justify-center rounded-lg border border-dark-border text-brand-primary transition-colors hover:border-brand-primary cursor-pointer"><RefreshCw className="h-4 w-4" /></button> <button type="button" onClick={() => void loadImportData()} title="Atualizar importações" aria-label="Atualizar importações" className="inline-flex h-8 w-8 items-center justify-center rounded-lg border border-dark-border text-brand-primary transition-colors hover:border-brand-primary cursor-pointer"><RefreshCw className="h-4 w-4" /></button>
</div> </div>
<div className="grid grid-cols-1 gap-3 p-5 md:grid-cols-4"> <div className="grid grid-cols-1 gap-3 p-5 sm:grid-cols-2 xl:grid-cols-5">
{(['BLCS', 'BLOS', 'BLMC', 'BLPM'] as const).map(familyKey => { {(['BLCS', 'BLOS', 'BLMC', 'BLPM', 'BLRM'] as const).map(familyKey => {
const recommendation = importData?.recommendations.find(item => item.familyKey === familyKey); const recommendation = importData?.recommendations.find(item => item.familyKey === familyKey);
return <div key={familyKey} className="rounded-xl border border-dark-border bg-dark-input/50 p-3"> return <div key={familyKey} className="rounded-xl border border-dark-border bg-dark-input/50 p-3">
<p className="text-[10px] font-bold uppercase tracking-widest text-dark-muted">{familyKey}</p> <p className="text-[10px] font-bold uppercase tracking-widest text-dark-muted">{familyKey} · {yieldFamilyLabels[familyKey]}</p>
<p className="mt-2 text-lg font-bold text-dark-text">{recommendation?.recommendedUnitsPerRoll ? `${Math.round(recommendation.recommendedUnitsPerRoll).toLocaleString('pt-BR')} un./rolo` : 'Sem sugestão'}</p> <p className="mt-2 text-lg font-bold text-dark-text">{recommendation?.recommendedUnitsPerRoll ? `${Math.round(recommendation.recommendedUnitsPerRoll).toLocaleString('pt-BR')} un./rolo` : 'Sem sugestão'}</p>
<p className="mt-1 text-xs font-semibold text-dark-muted">{recommendation ? `${recommendation.sampleCount.toLocaleString('pt-BR')} OPs · ${recommendation.yieldSampleCount.toLocaleString('pt-BR')} c/ rendimento` : 'Nenhuma OP importada'}</p> <p className="mt-1 text-xs font-semibold text-dark-muted">{recommendation ? `${recommendation.sampleCount.toLocaleString('pt-BR')} OPs · ${recommendation.yieldSampleCount.toLocaleString('pt-BR')} c/ rendimento` : 'Nenhuma OP importada'}</p>
</div>; </div>;
})} })}
</div> </div>
<div className="divide-y divide-dark-border"> <div className="border-t border-dark-border p-5">
{!importData?.runs.length && <p className="p-5 text-sm font-semibold text-dark-muted">Nenhum CSV de OPs finalizadas importado.</p>} <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
{importData?.runs.map(run => <div key={run.id} className="flex flex-col gap-2 p-4 md:flex-row md:items-center md:justify-between"> <div className="inline-flex w-fit rounded-xl border border-dark-border bg-dark-input/50 p-1">
<div><p className="text-sm font-bold text-dark-text">{run.filename}</p><p className="mt-1 text-xs font-semibold text-dark-muted">{formatDateTime(run.startedAt)} · {run.totalRows.toLocaleString('pt-BR')} linhas</p>{run.errorMessage && <p className="mt-1 line-clamp-1 text-xs font-semibold text-amber-200">{run.errorMessage}</p>}</div> <button type="button" onClick={() => setImportView('suppliers')} className={`rounded-lg px-3 py-2 text-xs font-bold transition-colors cursor-pointer ${importView === 'suppliers' ? 'bg-brand-primary text-brand-contrast' : 'text-dark-muted hover:text-dark-text'}`}>Referências por fornecedor</button>
<div className="flex flex-wrap gap-3 text-xs font-bold text-dark-muted"><span>{run.imported} novas</span><span>{run.updated} atualizadas</span><span>{run.skipped} ignoradas</span><span className={run.failed ? 'text-red-300' : ''}>{run.failed} falhas</span></div> <button type="button" onClick={() => setImportView('files')} className={`rounded-lg px-3 py-2 text-xs font-bold transition-colors cursor-pointer ${importView === 'files' ? 'bg-brand-primary text-brand-contrast' : 'text-dark-muted hover:text-dark-text'}`}>Arquivos importados</button>
</div>)} </div>
<p className="text-xs font-bold text-dark-muted">{importView === 'suppliers' ? `${supplierReferences.length.toLocaleString('pt-BR')} referências` : `${(importData?.runsTotal || 0).toLocaleString('pt-BR')} arquivos`}</p>
</div>
<div className="mt-4 overflow-hidden rounded-xl border border-dark-border">
{importView === 'suppliers' ? <>
<div className="grid grid-cols-[minmax(0,1fr)_auto] gap-3 border-b border-dark-border bg-dark-input/50 px-4 py-3 text-[10px] font-bold uppercase tracking-widest text-dark-muted"><span>Fornecedor e família</span><span>Rendimento</span></div>
<div className="divide-y divide-dark-border">
{!supplierReferences.length && <p className="p-5 text-sm font-semibold text-dark-muted">Nenhuma referência por fornecedor disponível.</p>}
{visibleSupplierReferences.map(item => <div key={`${item.familyKey}-${item.supplier}`} className="grid grid-cols-[minmax(0,1fr)_auto] gap-3 px-4 py-3 sm:items-center"><div><p className="text-sm font-bold text-dark-text">{item.supplier}</p><p className="mt-1 text-xs font-semibold text-dark-muted">{item.familyKey} · {yieldFamilyLabels[item.familyKey] || 'Família de corte'} · {item.sampleCount.toLocaleString('pt-BR')} OPs</p></div><p className="text-sm font-bold text-brand-primary">{Math.round(item.recommendedUnitsPerRoll!).toLocaleString('pt-BR')} un./rolo</p></div>)}
</div>
{!!supplierReferences.length && <PaginationControls page={Math.min(supplierReferencesPage, supplierReferencesTotalPages)} pageSize={PAGE_SIZE} total={supplierReferences.length} label="Referências" onChange={setSupplierReferencesPage} />}
</> : <>
<div className="grid grid-cols-[minmax(0,1fr)_auto] gap-3 border-b border-dark-border bg-dark-input/50 px-4 py-3 text-[10px] font-bold uppercase tracking-widest text-dark-muted"><span>Arquivo</span><span>Status da importação</span></div>
<div className="divide-y divide-dark-border">
{!importData?.runs.length && <p className="p-5 text-sm font-semibold text-dark-muted">Nenhum CSV de OPs finalizadas importado.</p>}
{importData?.runs.map(run => <div key={run.id} className="grid gap-3 px-4 py-4 lg:grid-cols-[minmax(0,1fr)_auto] lg:items-center"><div className="min-w-0"><p className="truncate text-sm font-bold text-dark-text">{run.filename}</p><p className="mt-1 text-xs font-semibold text-dark-muted">{formatDateTime(run.startedAt)} · {run.totalRows.toLocaleString('pt-BR')} linhas</p>{run.errorMessage && <p className="mt-1 line-clamp-1 text-xs font-semibold text-amber-200">{run.errorMessage}</p>}</div><div className="flex flex-wrap gap-x-3 gap-y-1 text-xs font-bold text-dark-muted"><span>{run.imported} novas</span><span>{run.updated} atualizadas</span><span>{run.skipped} ignoradas</span><span className={run.failed ? 'text-red-300' : ''}>{run.failed} falhas</span></div></div>)}
</div>
{!!importData && <PaginationControls page={importData.runsPage} pageSize={importData.runsPageSize} total={importData.runsTotal} label="Arquivos" onChange={setImportRunsPage} />}
</>}
</div>
</div> </div>
</section> </section>
<div className="overflow-hidden rounded-2xl border border-dark-border bg-dark-card">
<div className="border-b border-dark-border p-5"><h2 className="text-sm font-bold text-dark-text">Histórico de sincronizações</h2></div>
<div className="divide-y divide-dark-border">
{!status?.runs.length && <div className="p-8 text-center text-sm font-semibold text-dark-muted">Nenhuma sincronização iniciada.</div>}
{status?.runs.map(run => <div key={run.id} className="flex flex-col gap-3 p-4 md:flex-row md:items-center md:justify-between"><div><p className="text-sm font-bold text-dark-text">{run.mode === 'full' ? 'Carga completa' : 'Atualização incremental'} · {run.trigger === 'scheduled' ? 'Automática' : 'Manual'}</p><p className="mt-1 text-xs font-semibold text-dark-muted">{formatDateTime(run.startedAt)} · duração {formatDuration(run.startedAt, run.status === 'running' ? null : run.completedAt, now)}</p></div><div className="flex flex-wrap items-center gap-3 text-xs font-bold text-dark-muted"><span>{run.imported} comp.</span><span>{run.referenceCount} refs.</span><span className={run.failed ? 'text-red-300' : ''}>{run.failed} falhas</span><span className={`rounded-full border px-2.5 py-1 ${runTone(run)}`}>{runStatusLabel(run)}</span><button type="button" onClick={() => openRunDetails(run.id)} title="Ver logs e produtos afetados" aria-label="Ver logs e produtos afetados" className="inline-flex h-8 w-8 items-center justify-center rounded-lg border border-dark-border text-brand-primary transition-colors hover:border-brand-primary cursor-pointer"><ScrollText className="h-4 w-4" /></button></div></div>)}
</div>
</div>
{selectedRunId && ( {selectedRunId && (
<div className="fixed inset-0 z-50 flex items-end justify-center bg-black/70 p-0 backdrop-blur-sm sm:items-center sm:p-6" role="dialog" aria-modal="true" aria-label="Logs e produtos afetados da sincronização Olist"> <div className="fixed inset-0 z-50 flex items-end justify-center bg-black/70 p-0 backdrop-blur-sm sm:items-center sm:p-6" role="dialog" aria-modal="true" aria-label="Logs e produtos afetados da sincronização Olist">
<div className="max-h-[92vh] w-full max-w-6xl overflow-y-auto rounded-t-2xl border border-dark-border bg-dark-card shadow-2xl sm:rounded-2xl"> <div className="max-h-[92vh] w-full max-w-6xl overflow-y-auto rounded-t-2xl border border-dark-border bg-dark-card shadow-2xl sm:rounded-2xl">

View File

@@ -1,17 +1,16 @@
import { useEffect, useMemo, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import { Link, useOutletContext } from 'react-router-dom'; import { Link, useOutletContext } from 'react-router-dom';
import { AlertTriangle, CheckCircle2, Eye, PackageSearch, Pencil, Search } from 'lucide-react'; import { AlertTriangle, CheckCircle2, Download, Eye, History, PackageSearch, Pencil, RotateCcw, Save, Search, X } from 'lucide-react';
import DateRangePicker from '../components/DateRangePicker'; import DateRangePicker from '../components/DateRangePicker';
import PaginationControls from '../components/PaginationControls'; import PaginationControls from '../components/PaginationControls';
import ProductTypeBadge from '../components/ProductTypeBadge'; import ProductTypeBadge from '../components/ProductTypeBadge';
import RefreshStatus from '../components/RefreshStatus'; import RefreshStatus from '../components/RefreshStatus';
import SkuPlanningModal from '../components/SkuPlanningModal'; import SkuPlanningModal from '../components/SkuPlanningModal';
import { classifyCutFamily, CUT_FAMILY_RULES, type CutFamilyKey } from '../analytics/cutting'; import { classifyCutFamily, CUT_FAMILY_RULES, type CutFamilyKey } from '../analytics/cutting';
import { fetchCuttingSettings, fetchProductAnalytics, fetchStock, fetchSupplySummary, saveCuttingSettings } from '../dataService'; import { deleteGraphsStockBalance, exportToCSV, fetchCuttingSettings, fetchGraphsStockBalanceHistory, fetchProductAnalytics, fetchStock, fetchSupplySummary, saveCuttingSettings, saveGraphsStockBalance } from '../dataService';
import { buildConsumptionReferencePath } from '../catalogLinks';
import { getProductTypeConfig, resolveProductType, type ProductTypeKey } from '../productClassification'; import { getProductTypeConfig, resolveProductType, type ProductTypeKey } from '../productClassification';
import { parseProductName } from '../productParsing'; import { parseProductName } from '../productParsing';
import type { CutProductOverride, CuttingSettings, DateRange, ProductAnalyticsItem, StockData, SupplySummary } from '../types'; import type { CutProductOverride, CuttingSettings, DateRange, GraphsStockBalanceHistoryItem, ProductAnalyticsItem, StockData, SupplySummary } from '../types';
type IssueKey = 'review_type' | 'missing_cut_family' | 'missing_color' | 'missing_size' | 'missing_yield'; type IssueKey = 'review_type' | 'missing_cut_family' | 'missing_color' | 'missing_size' | 'missing_yield';
type IssueFilter = 'all' | IssueKey; type IssueFilter = 'all' | IssueKey;
@@ -141,6 +140,121 @@ const PlanningIssuesSkeleton = () => (
</div> </div>
); );
const formatDate = (value: string | null | undefined) => {
if (!value) return '—';
const date = new Date(value);
return Number.isNaN(date.getTime()) ? '—' : new Intl.DateTimeFormat('pt-BR', { dateStyle: 'short' }).format(date);
};
const StockGraphsBalanceModal = ({
stock,
onClose,
onSaved
}: {
stock: StockData;
onClose: () => void;
onSaved: (stock: StockData) => void;
}) => {
const [balance, setBalance] = useState(stock.graphs_balance === null || stock.graphs_balance === undefined ? '' : String(stock.graphs_balance));
const [reason, setReason] = useState(stock.graphs_balance_reason || 'Conferência física de estoque');
const [effectiveAt, setEffectiveAt] = useState(stock.graphs_balance_effective_at?.slice(0, 10) || new Date().toISOString().slice(0, 10));
const [history, setHistory] = useState<GraphsStockBalanceHistoryItem[]>([]);
const [isLoadingHistory, setIsLoadingHistory] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const [error, setError] = useState('');
const loadHistory = useCallback(async () => {
setIsLoadingHistory(true);
try {
setHistory(await fetchGraphsStockBalanceHistory(stock.produto_id));
} catch (historyError) {
setError(historyError instanceof Error ? historyError.message : 'Não foi possível carregar o histórico.');
} finally {
setIsLoadingHistory(false);
}
}, [stock.produto_id]);
useEffect(() => {
const timer = window.setTimeout(() => { void loadHistory(); }, 0);
return () => window.clearTimeout(timer);
}, [loadHistory]);
const save = async () => {
const normalizedBalance = Number(balance.replace(',', '.'));
if (!Number.isFinite(normalizedBalance)) {
setError('Informe um saldo físico válido.');
return;
}
setIsSaving(true);
setError('');
try {
const saved = await saveGraphsStockBalance(stock.produto_id, { balance: normalizedBalance, reason, effectiveAt });
onSaved(saved);
setBalance(String(saved.graphs_balance ?? normalizedBalance));
await loadHistory();
} catch (saveError) {
setError(saveError instanceof Error ? saveError.message : 'Não foi possível salvar o saldo Graphs.');
} finally {
setIsSaving(false);
}
};
const remove = async () => {
if (!window.confirm('Remover o saldo Graphs e voltar a usar o saldo importado do Tiny no planejamento?')) return;
setIsSaving(true);
setError('');
try {
const saved = await deleteGraphsStockBalance(stock.produto_id);
onSaved(saved);
setBalance('');
await loadHistory();
} catch (removeError) {
setError(removeError instanceof Error ? removeError.message : 'Não foi possível remover o saldo Graphs.');
} finally {
setIsSaving(false);
}
};
const planningBalance = Math.max(0, Number(stock.planning_balance ?? stock.saldo));
return <div className="fixed inset-0 z-50 flex h-dvh items-center justify-center overflow-y-auto bg-black/75 p-4 backdrop-blur-md" role="dialog" aria-modal="true" aria-label={`Saldo Graphs de ${stock.nome}`} 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">
<p className="font-mono text-[10px] font-bold uppercase tracking-widest text-dark-muted">Material #{stock.produto_id}</p>
<h2 className="mt-1 truncate text-lg font-bold text-dark-text" title={stock.nome}>{stock.nome}</h2>
<p className="mt-2 text-sm font-semibold text-dark-muted">O saldo Tiny permanece preservado para auditoria. O saldo Graphs passa a ser a base do planejamento.</p>
</div>
<button type="button" onClick={onClose} title="Fechar" aria-label="Fechar" 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"><X className="h-4 w-4" /></button>
</div>
<div className="grid gap-3 p-5 sm:grid-cols-3">
<div className="rounded-xl border border-dark-border bg-dark-input/50 p-3"><p className="text-[10px] font-bold uppercase tracking-widest text-dark-muted">Saldo Tiny</p><p className="mt-2 text-lg font-bold text-red-300">{formatNumber(Number(stock.saldo), 2)} un.</p></div>
<div className="rounded-xl border border-dark-border bg-dark-input/50 p-3"><p className="text-[10px] font-bold uppercase tracking-widest text-dark-muted">Saldo Graphs</p><p className="mt-2 text-lg font-bold text-brand-primary">{stock.graphs_balance === null || stock.graphs_balance === undefined ? 'Não informado' : `${formatNumber(Number(stock.graphs_balance), 2)} un.`}</p></div>
<div className="rounded-xl border border-dark-border bg-dark-input/50 p-3"><p className="text-[10px] font-bold uppercase tracking-widest text-dark-muted">Disponível no plano</p><p className="mt-2 text-lg font-bold text-emerald-300">{formatNumber(planningBalance, 2)} un.</p></div>
</div>
<div className="grid gap-4 border-t border-dark-border p-5 md:grid-cols-[minmax(0,1fr)_minmax(0,1.2fr)]">
<div className="space-y-4">
<label className="block space-y-2"><span className="text-xs font-bold uppercase tracking-widest text-dark-muted">Saldo físico no Graphs</span><input type="text" inputMode="decimal" value={balance} onChange={(event) => setBalance(event.target.value)} placeholder="Ex.: 850,50" 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="block space-y-2"><span className="text-xs font-bold uppercase tracking-widest text-dark-muted">Data da conferência</span><input type="date" value={effectiveAt} onChange={(event) => setEffectiveAt(event.target.value)} 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" /></label>
<label className="block space-y-2"><span className="text-xs font-bold uppercase tracking-widest text-dark-muted">Motivo</span><textarea value={reason} onChange={(event) => setReason(event.target.value)} rows={3} placeholder="Ex.: inventário físico do depósito" 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>
{error && <p className="rounded-lg border border-red-400/30 bg-red-400/10 px-3 py-2 text-xs font-bold text-red-300">{error}</p>}
<div className="flex flex-wrap justify-between gap-2"><button type="button" onClick={() => void remove()} disabled={isSaving || stock.graphs_balance === null || stock.graphs_balance === undefined} className="inline-flex h-10 items-center gap-2 rounded-lg border border-dark-border px-3 text-sm font-bold text-dark-muted transition-colors hover:border-red-400/40 hover:text-red-300 disabled:cursor-not-allowed disabled:opacity-40 cursor-pointer"><RotateCcw className="h-4 w-4" /> Usar Tiny</button><button type="button" onClick={() => void save()} disabled={isSaving} className="inline-flex h-10 items-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 cursor-pointer"><Save className="h-4 w-4" /> {isSaving ? 'Salvando...' : 'Salvar saldo Graphs'}</button></div>
</div>
<div className="overflow-hidden rounded-xl border border-dark-border">
<div className="flex items-center gap-2 border-b border-dark-border bg-dark-input/50 px-4 py-3"><History className="h-4 w-4 text-brand-primary" /><h3 className="text-sm font-bold text-dark-text">Histórico de alterações</h3></div>
<div className="max-h-80 divide-y divide-dark-border overflow-y-auto">
{isLoadingHistory && <p className="p-4 text-sm font-semibold text-dark-muted">Carregando histórico...</p>}
{!isLoadingHistory && !history.length && <p className="p-4 text-sm font-semibold text-dark-muted">Nenhuma alteração manual registrada.</p>}
{history.map(item => <div key={item.id} className="p-4"><div className="flex items-center justify-between gap-3"><p className="text-sm font-bold text-dark-text">{item.action === 'removed' ? 'Saldo Graphs removido' : `${formatNumber(Number(item.graphs_balance || 0), 2)} un.`}</p><span className="text-xs font-semibold text-dark-muted">{formatDate(item.created_at)}</span></div><p className="mt-1 text-xs font-semibold text-dark-muted">Tiny: {formatNumber(Number(item.tiny_balance || 0), 2)} un. · referência {formatDate(item.effective_at)}</p>{item.reason && <p className="mt-2 text-xs font-medium text-dark-text">{item.reason}</p>}{item.changed_by && <p className="mt-1 text-[11px] font-semibold text-dark-muted">Por {item.changed_by}</p>}</div>)}
</div>
</div>
</div>
</div>
</div>;
};
const PlanningIssues = () => { const PlanningIssues = () => {
const { dateRange, setDateRange } = useOutletContext<{ const { dateRange, setDateRange } = useOutletContext<{
dateRange: DateRange, dateRange: DateRange,
@@ -155,9 +269,10 @@ const PlanningIssues = () => {
const [searchTerm, setSearchTerm] = useState(''); const [searchTerm, setSearchTerm] = useState('');
const [issueFilter, setIssueFilter] = useState<IssueFilter>('all'); const [issueFilter, setIssueFilter] = useState<IssueFilter>('all');
const [editingProduct, setEditingProduct] = useState<PlanningIssueRow | null>(null); const [editingProduct, setEditingProduct] = useState<PlanningIssueRow | null>(null);
const [editingStock, setEditingStock] = useState<StockData | null>(null);
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
const [currentPage, setCurrentPage] = useState(1); const [currentPage, setCurrentPage] = useState(1);
const [itemsPerPage, setItemsPerPage] = useState(20); const [itemsPerPage, setItemsPerPage] = useState(10);
useEffect(() => { useEffect(() => {
let isMounted = true; let isMounted = true;
@@ -285,6 +400,52 @@ const PlanningIssues = () => {
} }
}; };
const saveGraphsBalance = (updatedStock: StockData) => {
setStockSnapshot(current => current.map(item => (
item.produto_id === updatedStock.produto_id ? updatedStock : item
)));
setEditingStock(updatedStock);
};
const exportActiveQueue = () => {
const dateSuffix = new Date().toISOString().slice(0, 10);
if (activeQueue === 'negative_stock') {
exportToCSV(visibleNegativeStockRows.map(row => ({
sku: row.produto_id,
material: row.nome,
saldo_tiny: Number(row.saldo || 0),
saldo_graphs: row.graphs_balance ?? '',
saldo_planejamento: Math.max(0, Number(row.planning_balance ?? row.saldo ?? 0))
})), `saldos-negativos-${dateSuffix}`);
return;
}
if (activeQueue === 'missing_composition') {
exportToCSV(visibleMissingCompositionRows.map(row => ({
sku: row.productId,
produto: row.name,
vendido: row.quantitySold,
estoque: row.stockQuantity,
corte_sugerido: row.suggestedQuantity,
pendencia: 'Sem composição confiável'
})), `skus-sem-composicao-${dateSuffix}`);
return;
}
exportToCSV(rows.map(row => ({
sku: row.id,
produto: row.name,
tipo: getProductTypeConfig(row.productType).label,
familia: row.familyLabel,
cor: row.color || 'Não informado',
tamanho: row.size || 'Não informado',
pendencias: row.issues.map(issue => issueLabels[issue]).join(', '),
vendido: row.quantitySold,
estoque: row.stock
})), `cadastro-para-corte-${dateSuffix}`);
};
const activeRowCount = activeQueue === 'negative_stock' const activeRowCount = activeQueue === 'negative_stock'
? visibleNegativeStockRows.length ? visibleNegativeStockRows.length
: activeQueue === 'missing_composition' : activeQueue === 'missing_composition'
@@ -308,13 +469,14 @@ const PlanningIssues = () => {
</p> </p>
</div> </div>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center 2xl:justify-end"> <div className="flex flex-col gap-3 sm:flex-row sm:items-center 2xl:justify-end">
<Link <button
to="/registrations" type="button"
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" onClick={exportActiveQueue}
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"
> >
<PackageSearch className="h-4 w-4 text-brand-primary" /> <Download className="h-4 w-4 text-brand-primary" />
Cadastros Exportar CSV
</Link> </button>
<DateRangePicker <DateRangePicker
dateRange={dateRange} dateRange={dateRange}
onChange={(range) => { onChange={(range) => {
@@ -594,32 +756,46 @@ const PlanningIssues = () => {
) : activeQueue === 'negative_stock' ? ( ) : activeQueue === 'negative_stock' ? (
visibleNegativeStockRows.length ? ( visibleNegativeStockRows.length ? (
<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 px-5 py-4"> <div className="flex flex-col gap-1 border-b border-dark-border px-5 py-4 sm:flex-row sm:items-center sm:justify-between">
<h2 className="text-base font-bold text-dark-text">Saldos negativos</h2> <div><h2 className="text-base font-bold text-dark-text">Saldos negativos</h2><p className="mt-1 text-sm font-semibold text-dark-muted">O saldo Tiny continua visível para auditoria. Informe uma conferência no Graphs para substituir sua base no planejamento.</p></div>
<p className="mt-1 text-sm font-semibold text-dark-muted">São exibidos para conferência, mas contam como zero disponível no planejamento.</p> <span className="w-fit rounded-full border border-red-400/25 bg-red-400/10 px-2.5 py-1 text-xs font-bold text-red-300">{formatNumber(visibleNegativeStockRows.length)} materiais</span>
</div> </div>
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full min-w-[860px] text-left text-sm"> <table className="w-full min-w-[920px] table-fixed text-left text-sm">
<colgroup>
<col className="w-[44%]" />
<col className="w-[15%]" />
<col className="w-[15%]" />
<col className="w-[18%]" />
<col className="w-[8%]" />
</colgroup>
<thead className="border-b border-dark-border bg-dark-header text-dark-muted"> <thead className="border-b border-dark-border bg-dark-header text-dark-muted">
<tr> <tr>
<th className="px-5 py-4 text-[10px] font-bold uppercase tracking-wider">SKU</th> <th className="px-5 py-3 text-[10px] font-bold uppercase tracking-wider">Material</th>
<th className="px-5 py-4 text-[10px] font-bold uppercase tracking-wider">Material</th> <th className="px-5 py-3 text-right text-[10px] font-bold uppercase tracking-wider">Saldo Tiny</th>
<th className="px-5 py-4 text-right text-[10px] font-bold uppercase tracking-wider">Saldo informado</th> <th className="px-5 py-3 text-right text-[10px] font-bold uppercase tracking-wider">Saldo Graphs</th>
<th className="px-5 py-4 text-[10px] font-bold uppercase tracking-wider">Tratamento no plano</th> <th className="px-5 py-3 text-center text-[10px] font-bold uppercase tracking-wider">Planejamento</th>
<th className="px-5 py-4 text-right text-[10px] font-bold uppercase tracking-wider">Ação</th> <th className="px-5 py-3 text-right text-[10px] font-bold uppercase tracking-wider">Ações</th>
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-dark-border"> <tbody className="divide-y divide-dark-border">
{paginatedNegativeStockRows.map(row => ( {paginatedNegativeStockRows.map(row => (
<tr key={row.produto_id} className="transition-colors hover:bg-dark-input/50"> <tr key={row.produto_id} className="transition-colors hover:bg-dark-input/50">
<td className="px-5 py-3 font-mono text-xs font-semibold text-dark-muted">#{row.produto_id}</td> <td className="max-w-0 px-5 py-3"><p className="truncate font-bold text-dark-text" title={row.nome}>{row.nome}</p><p className="mt-1 font-mono text-[11px] font-semibold text-dark-muted">#{row.produto_id}</p></td>
<td className="max-w-[420px] px-5 py-3"><p className="truncate font-bold text-dark-text" title={row.nome}>{row.nome}</p></td> <td className="px-5 py-3 text-right"><p className="font-bold text-red-300">{formatNumber(Number(row.saldo), 2)} un.</p><p className="mt-1 text-[11px] font-semibold text-dark-muted">importado do Tiny</p></td>
<td className="px-5 py-3 text-right font-bold text-red-300">{formatNumber(Number(row.saldo), 2)} un.</td>
<td className="px-5 py-3"><span className="inline-flex rounded-full border border-dark-border bg-dark-input px-2.5 py-1 text-xs font-bold text-dark-muted">0 disponível</span></td>
<td className="px-5 py-3 text-right"> <td className="px-5 py-3 text-right">
<Link to="/supplies/inventory?tab=stock" className="inline-flex h-8 items-center gap-1.5 rounded-lg bg-dark-input px-2.5 text-xs font-bold text-dark-text transition-colors hover:bg-dark-border cursor-pointer"> {row.graphs_balance === null || row.graphs_balance === undefined ? (
Conferir <Eye className="h-3.5 w-3.5" /> <p className="text-xs font-bold text-dark-muted">Não informado</p>
</Link> ) : (
<><p className="font-bold text-brand-primary">{formatNumber(Number(row.graphs_balance), 2)} un.</p><p className="mt-1 text-[11px] font-semibold text-dark-muted">conferido no Graphs</p></>
)}
</td>
<td className="px-5 py-3 text-center"><span className={`inline-flex rounded-full border px-2.5 py-1 text-xs font-bold ${Number(row.planning_balance ?? row.saldo) > 0 ? 'border-emerald-400/25 bg-emerald-400/10 text-emerald-300' : 'border-dark-border bg-dark-input text-dark-muted'}`}>Usa {formatNumber(Math.max(0, Number(row.planning_balance ?? row.saldo)), 2)} disponível</span></td>
<td className="px-5 py-3 text-right">
<div className="flex justify-end gap-2">
<button type="button" onClick={() => setEditingStock(row)} title={`Conferir saldo de ${row.nome}`} aria-label={`Conferir saldo de ${row.nome}`} className="inline-flex h-8 w-8 items-center justify-center rounded-lg border border-dark-border bg-dark-input text-dark-text transition-colors hover:border-brand-primary hover:text-brand-primary cursor-pointer"><Pencil className="h-3.5 w-3.5" /></button>
<Link to="/supplies/inventory?tab=stock" title={`Ver ${row.nome} no estoque`} aria-label={`Ver ${row.nome} no estoque`} className="inline-flex h-8 w-8 items-center justify-center rounded-lg border border-dark-border bg-dark-input text-brand-primary transition-colors hover:border-brand-primary cursor-pointer"><Eye className="h-3.5 w-3.5" /></Link>
</div>
</td> </td>
</tr> </tr>
))} ))}
@@ -648,33 +824,31 @@ const PlanningIssues = () => {
) )
) : visibleMissingCompositionRows.length ? ( ) : visibleMissingCompositionRows.length ? (
<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 px-5 py-4"> <div className="flex flex-col gap-1 border-b border-dark-border px-5 py-4 sm:flex-row sm:items-center sm:justify-between">
<h2 className="text-base font-bold text-dark-text">SKUs sem composição</h2> <div><h2 className="text-base font-bold text-dark-text">SKUs sem composição</h2><p className="mt-1 text-sm font-semibold text-dark-muted">Continuam operando, mas não entram na necessidade de materiais até receberem uma composição.</p></div>
<p className="mt-1 text-sm font-semibold text-dark-muted">Estes produtos continuam operando, mas não geram demanda de material até receberem composição.</p> <span className="w-fit rounded-full border border-sky-400/25 bg-sky-400/10 px-2.5 py-1 text-xs font-bold text-sky-300">{formatNumber(visibleMissingCompositionRows.length)} SKUs</span>
</div> </div>
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full min-w-[940px] text-left text-sm"> <table className="w-full min-w-[760px] text-left text-sm">
<thead className="border-b border-dark-border bg-dark-header text-dark-muted"> <thead className="border-b border-dark-border bg-dark-header text-dark-muted">
<tr> <tr>
<th className="px-5 py-4 text-[10px] font-bold uppercase tracking-wider">SKU</th> <th className="px-5 py-3 text-[10px] font-bold uppercase tracking-wider">Produto</th>
<th className="px-5 py-4 text-[10px] font-bold uppercase tracking-wider">Produto</th> <th className="px-5 py-3 text-right text-[10px] font-bold uppercase tracking-wider">Vendido</th>
<th className="px-5 py-4 text-right text-[10px] font-bold uppercase tracking-wider">Vendido</th> <th className="px-5 py-3 text-right text-[10px] font-bold uppercase tracking-wider">Estoque</th>
<th className="px-5 py-4 text-right text-[10px] font-bold uppercase tracking-wider">Estoque</th> <th className="px-5 py-3 text-right text-[10px] font-bold uppercase tracking-wider">Cortar agora</th>
<th className="px-5 py-4 text-right text-[10px] font-bold uppercase tracking-wider">Sugestão de corte</th> <th className="px-5 py-3 text-right text-[10px] font-bold uppercase tracking-wider">Ações</th>
<th className="px-5 py-4 text-right text-[10px] font-bold uppercase tracking-wider">Ações</th>
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-dark-border"> <tbody className="divide-y divide-dark-border">
{paginatedMissingCompositionRows.map(row => ( {paginatedMissingCompositionRows.map(row => (
<tr key={row.productId} className="transition-colors hover:bg-dark-input/50"> <tr key={row.productId} className="transition-colors hover:bg-dark-input/50">
<td className="px-5 py-3 font-mono text-xs font-semibold text-dark-muted">#{row.productId}</td> <td className="max-w-0 px-5 py-3"><p className="truncate font-bold text-dark-text" title={row.name}>{row.name}</p><p className="mt-1 font-mono text-[11px] font-semibold text-dark-muted">#{row.productId}</p></td>
<td className="max-w-[420px] px-5 py-3"><p className="truncate font-bold text-dark-text" title={row.name}>{row.name}</p></td>
<td className="px-5 py-3 text-right font-bold text-dark-text">{formatNumber(row.quantitySold)} un.</td> <td className="px-5 py-3 text-right font-bold text-dark-text">{formatNumber(row.quantitySold)} un.</td>
<td className="px-5 py-3 text-right font-bold text-dark-text">{formatNumber(row.stockQuantity)} un.</td> <td className="px-5 py-3 text-right font-bold text-dark-text">{formatNumber(row.stockQuantity)} un.</td>
<td className="px-5 py-3 text-right font-bold text-amber-300">{formatNumber(row.suggestedQuantity)} un.</td> <td className="px-5 py-3 text-right font-bold text-amber-300">{formatNumber(row.suggestedQuantity)} un.</td>
<td className="px-5 py-3 text-right"> <td className="px-5 py-3 text-right">
<div className="flex justify-end gap-2"> <div className="flex justify-end gap-2">
<Link to={buildConsumptionReferencePath({ sku: row.productId, name: row.name })} 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="Cadastrar composição" aria-label={`Cadastrar composição de ${row.name}`}><Pencil className="h-3.5 w-3.5" /></Link> <Link to={`/products/${row.productId}`} className="inline-flex h-8 w-8 items-center justify-center rounded-lg border border-dark-border bg-dark-input text-dark-text transition-colors hover:border-brand-primary cursor-pointer" title="Cadastrar composição" aria-label={`Cadastrar composição de ${row.name}`}><Pencil className="h-3.5 w-3.5" /></Link>
<Link to={`/products/${row.productId}`} 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 produto" aria-label={`Ver produto ${row.name}`}><Eye className="h-3.5 w-3.5" /></Link> <Link to={`/products/${row.productId}`} 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 produto" aria-label={`Ver produto ${row.name}`}><Eye className="h-3.5 w-3.5" /></Link>
</div> </div>
</td> </td>
@@ -715,6 +889,13 @@ const PlanningIssues = () => {
onSave={(override) => saveProductOverride(editingProduct.id, override)} onSave={(override) => saveProductOverride(editingProduct.id, override)}
/> />
)} )}
{editingStock && (
<StockGraphsBalanceModal
stock={editingStock}
onClose={() => setEditingStock(null)}
onSaved={saveGraphsBalance}
/>
)}
</div> </div>
); );
}; };

View File

@@ -1,6 +1,6 @@
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, Pencil, ReceiptText, Warehouse } from 'lucide-react'; import { Package, DollarSign, Pencil, Plus, ReceiptText, Trash2, Warehouse, X } 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';
@@ -9,7 +9,7 @@ import ProductTypeBadge from '../components/ProductTypeBadge';
import RefreshStatus from '../components/RefreshStatus'; import RefreshStatus from '../components/RefreshStatus';
import PaginationControls from '../components/PaginationControls'; import PaginationControls from '../components/PaginationControls';
import type { CutProductOverride, CuttingSettings, DateRange, ProductComposition, ProductDetailsAnalytics } from '../types'; import type { CutProductOverride, CuttingSettings, DateRange, ProductComposition, ProductDetailsAnalytics } from '../types';
import { fetchCuttingSettings, fetchProductComposition, fetchProductDetailsAnalytics, saveCuttingSettings } from '../dataService'; import { deleteManualProductComposition, fetchCuttingSettings, fetchProductComposition, fetchProductDetailsAnalytics, saveCuttingSettings, saveManualProductComposition } 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';
@@ -24,6 +24,18 @@ const VARIANT_BAR_COLOR = '#52DFA0';
type ProductChartMetric = 'quantity' | 'revenue' | 'ticket'; type ProductChartMetric = 'quantity' | 'revenue' | 'ticket';
type CompositionFormComponent = {
componentTinyId: string;
componentSku: string;
componentName: string;
quantityPerUnit: string;
unit: string;
};
const emptyCompositionComponent = (): CompositionFormComponent => ({
componentTinyId: '', componentSku: '', componentName: '', quantityPerUnit: '', unit: 'KG'
});
type ProductMetricChartPoint = ProductDetailsAnalytics['chartData'][number] & { type ProductMetricChartPoint = ProductDetailsAnalytics['chartData'][number] & {
selectedValue: number; selectedValue: number;
movingAverage?: number; movingAverage?: number;
@@ -142,6 +154,10 @@ const ProductDetails = () => {
const [isSavingPlanning, setIsSavingPlanning] = useState(false); const [isSavingPlanning, setIsSavingPlanning] = useState(false);
const [variantCurrentPage, setVariantCurrentPage] = useState(1); const [variantCurrentPage, setVariantCurrentPage] = useState(1);
const [variantItemsPerPage, setVariantItemsPerPage] = useState(5); const [variantItemsPerPage, setVariantItemsPerPage] = useState(5);
const [isCompositionModalOpen, setIsCompositionModalOpen] = useState(false);
const [compositionForm, setCompositionForm] = useState<CompositionFormComponent[]>([emptyCompositionComponent()]);
const [isSavingComposition, setIsSavingComposition] = useState(false);
const [compositionError, setCompositionError] = useState('');
useEffect(() => { useEffect(() => {
const scrollContainer = document.querySelector<HTMLElement>('.app-content'); const scrollContainer = document.querySelector<HTMLElement>('.app-content');
@@ -231,6 +247,65 @@ const ProductDetails = () => {
} }
}; };
const openCompositionEditor = () => {
setCompositionForm(composition?.components.length
? composition.components.map(component => ({
componentTinyId: component.componentTinyId,
componentSku: component.componentSku,
componentName: component.componentName,
quantityPerUnit: String(component.quantityPerUnit || ''),
unit: component.unit || 'KG'
}))
: [emptyCompositionComponent()]);
setCompositionError('');
setIsCompositionModalOpen(true);
};
const saveComposition = async () => {
if (!id || !details?.productInfo) return;
const components = compositionForm.map(component => ({
...component,
componentName: component.componentName.trim(),
componentSku: component.componentSku.trim(),
componentTinyId: component.componentTinyId.trim(),
unit: component.unit.trim().toUpperCase(),
quantityPerUnit: Number(component.quantityPerUnit.replace(',', '.'))
}));
if (!components.length || components.some(component => !component.componentName || !component.unit || !Number.isFinite(component.quantityPerUnit) || component.quantityPerUnit <= 0)) {
setCompositionError('Informe nome, quantidade positiva e unidade para todos os insumos.');
return;
}
setIsSavingComposition(true);
setCompositionError('');
try {
const savedComposition = await saveManualProductComposition(id, {
productSku: composition?.finishedProductSku || '',
productDescription: details.productInfo.name,
productUnit: composition?.finishedProductUnit || 'UN',
components
});
setComposition(savedComposition);
setIsCompositionModalOpen(false);
} catch (saveError) {
setCompositionError(saveError instanceof Error ? saveError.message : 'Não foi possível salvar a composição.');
} finally {
setIsSavingComposition(false);
}
};
const removeManualComposition = async () => {
if (!id || !window.confirm('Excluir a composição manual? A composição sincronizada da Olist, se existir, voltará a ser usada.')) return;
setIsSavingComposition(true);
try {
await deleteManualProductComposition(id);
setComposition(await fetchProductComposition(id));
} catch (deleteError) {
setCompositionError(deleteError instanceof Error ? deleteError.message : 'Não foi possível excluir a composição manual.');
} finally {
setIsSavingComposition(false);
}
};
if (isLoading && !details) { if (isLoading && !details) {
return <ProductDetailsSkeleton />; return <ProductDetailsSkeleton />;
} }
@@ -557,13 +632,21 @@ const ProductDetails = () => {
</div> </div>
<section className="bg-white dark:bg-dark-card border border-zinc-200 dark:border-dark-border rounded-2xl p-6 shadow-sm"> <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"> <div className="mb-5 flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<h3 className="text-lg font-bold text-zinc-900 dark:text-dark-text">Composição</h3> <div>
{composition && ( <h3 className="text-lg font-bold text-zinc-900 dark:text-dark-text">Composição</h3>
<p className="mt-1 text-sm font-medium text-zinc-500 dark:text-dark-muted"> {composition ? (
Para produzir 1 UN de {composition.finishedProductSku || productInfo.id} <p className="mt-1 text-sm font-medium text-zinc-500 dark:text-dark-muted">
</p> Para produzir 1 UN de {composition.finishedProductSku || productInfo.id}{composition.source === 'manual' ? ' · Cadastro manual' : ''}
)} </p>
) : <p className="mt-1 text-sm font-medium text-zinc-500 dark:text-dark-muted">Cadastre os insumos usados para produzir este produto.</p>}
</div>
<div className="flex items-center gap-2">
{composition?.source === 'manual' && (
<button type="button" onClick={() => void removeManualComposition()} disabled={isSavingComposition} title="Excluir composição manual" aria-label="Excluir composição manual" className="inline-flex h-9 w-9 items-center justify-center rounded-lg border border-red-400/30 text-red-300 transition-colors hover:bg-red-400/10 disabled:opacity-50 cursor-pointer"><Trash2 className="h-4 w-4" /></button>
)}
<button type="button" onClick={openCompositionEditor} title={composition ? 'Editar composição' : 'Adicionar composição'} className="inline-flex h-9 items-center gap-2 rounded-lg border border-dark-border px-3 text-xs font-bold text-dark-text transition-colors hover:border-brand-primary cursor-pointer">{composition ? <Pencil className="h-4 w-4 text-brand-primary" /> : <Plus className="h-4 w-4 text-brand-primary" />} {composition ? 'Editar' : 'Adicionar'}</button>
</div>
</div> </div>
{isCompositionLoading ? ( {isCompositionLoading ? (
<div className="flex h-24 items-center text-sm font-semibold text-zinc-500 dark:text-dark-muted">Carregando composição…</div> <div className="flex h-24 items-center text-sm font-semibold text-zinc-500 dark:text-dark-muted">Carregando composição…</div>
@@ -607,6 +690,36 @@ const ProductDetails = () => {
)} )}
</section> </section>
{isCompositionModalOpen && (
<div className="fixed inset-0 z-50 flex items-end justify-center bg-black/70 p-0 backdrop-blur-sm sm:items-center sm:p-6" role="dialog" aria-modal="true" aria-label="Editar composição">
<div className="max-h-[92vh] w-full max-w-4xl overflow-y-auto rounded-t-2xl border border-dark-border bg-dark-card shadow-2xl sm:rounded-2xl">
<div className="sticky top-0 z-10 flex items-start justify-between border-b border-dark-border bg-dark-card p-5">
<div><h2 className="text-base font-bold text-dark-text">{composition ? 'Editar composição' : 'Adicionar composição'}</h2><p className="mt-1 text-xs font-semibold text-dark-muted">Informe o consumo necessário para produzir uma unidade.</p></div>
<button type="button" onClick={() => setIsCompositionModalOpen(false)} title="Fechar" aria-label="Fechar" className="inline-flex h-9 w-9 items-center justify-center rounded-lg border border-dark-border text-dark-muted transition-colors hover:border-brand-primary hover:text-dark-text cursor-pointer"><X className="h-4 w-4" /></button>
</div>
<div className="p-5">
{compositionError && <p className="mb-4 rounded-xl border border-red-400/30 bg-red-400/10 p-3 text-sm font-semibold text-red-300">{compositionError}</p>}
<div className="overflow-x-auto rounded-xl border border-dark-border">
<table className="w-full min-w-[760px] text-left text-sm">
<thead className="bg-dark-input/60 text-[10px] font-bold uppercase tracking-widest text-dark-muted"><tr><th className="px-3 py-3">SKU ou ID</th><th className="px-3 py-3">Insumo</th><th className="px-3 py-3">Quantidade</th><th className="px-3 py-3">Unidade</th><th className="w-12 px-3 py-3"><span className="sr-only">Remover</span></th></tr></thead>
<tbody className="divide-y divide-dark-border">
{compositionForm.map((component, index) => <tr key={`${index}-${component.componentSku}-${component.componentName}`}>
<td className="p-2"><input value={component.componentSku || component.componentTinyId} onChange={event => setCompositionForm(items => items.map((item, itemIndex) => itemIndex === index ? { ...item, componentSku: event.target.value, componentTinyId: '' } : item))} placeholder="MC.30.PTO" className="h-9 w-full rounded-lg border border-dark-border bg-dark-input px-3 text-sm text-dark-text outline-none focus:border-brand-primary" /></td>
<td className="p-2"><input value={component.componentName} onChange={event => setCompositionForm(items => items.map((item, itemIndex) => itemIndex === index ? { ...item, componentName: event.target.value } : item))} placeholder="Nome do insumo" className="h-9 w-full rounded-lg border border-dark-border bg-dark-input px-3 text-sm text-dark-text outline-none focus:border-brand-primary" /></td>
<td className="p-2"><input type="number" min="0" step="0.0001" value={component.quantityPerUnit} onChange={event => setCompositionForm(items => items.map((item, itemIndex) => itemIndex === index ? { ...item, quantityPerUnit: event.target.value } : item))} placeholder="0,0000" className="h-9 w-28 rounded-lg border border-dark-border bg-dark-input px-3 text-sm text-dark-text outline-none focus:border-brand-primary" /></td>
<td className="p-2"><input value={component.unit} onChange={event => setCompositionForm(items => items.map((item, itemIndex) => itemIndex === index ? { ...item, unit: event.target.value.toUpperCase() } : item))} placeholder="KG" className="h-9 w-20 rounded-lg border border-dark-border bg-dark-input px-3 text-sm text-dark-text outline-none focus:border-brand-primary" /></td>
<td className="p-2 text-center"><button type="button" onClick={() => setCompositionForm(items => items.filter((_, itemIndex) => itemIndex !== index))} disabled={compositionForm.length === 1} title="Remover insumo" aria-label="Remover insumo" className="inline-flex h-8 w-8 items-center justify-center rounded-lg border border-dark-border text-dark-muted transition-colors hover:border-red-400/50 hover:text-red-300 disabled:cursor-not-allowed disabled:opacity-40 cursor-pointer"><Trash2 className="h-3.5 w-3.5" /></button></td>
</tr>)}
</tbody>
</table>
</div>
<button type="button" onClick={() => setCompositionForm(items => [...items, emptyCompositionComponent()])} className="mt-3 inline-flex h-9 items-center gap-2 rounded-lg border border-dark-border px-3 text-xs font-bold text-dark-text transition-colors hover:border-brand-primary cursor-pointer"><Plus className="h-4 w-4 text-brand-primary" /> Adicionar insumo</button>
</div>
<div className="flex justify-end gap-2 border-t border-dark-border p-5"><button type="button" onClick={() => setIsCompositionModalOpen(false)} className="h-10 rounded-xl border border-dark-border px-4 text-sm font-bold text-dark-text cursor-pointer">Cancelar</button><button type="button" onClick={() => void saveComposition()} disabled={isSavingComposition} className="inline-flex h-10 items-center gap-2 rounded-xl bg-brand-primary px-4 text-sm font-bold text-brand-contrast disabled:opacity-60 cursor-pointer">{isSavingComposition ? 'Salvando...' : 'Salvar composição'}</button></div>
</div>
</div>
)}
<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-5 flex flex-col gap-2 md:flex-row md:items-end md:justify-between"> <div className="mb-5 flex flex-col gap-2 md:flex-row md:items-end md:justify-between">
<div> <div>

View File

@@ -1,30 +1,34 @@
import { Fragment, type FormEvent, useCallback, useEffect, useMemo, useState } from 'react'; import { type FormEvent, useCallback, useEffect, useMemo, useState } from 'react';
import { Link, useOutletContext } from 'react-router-dom'; import { Link, useNavigate, useOutletContext } from 'react-router-dom';
import { ArrowLeft, CheckCircle2, ChevronDown, ChevronUp, ClipboardList, Clock3, Download, PackageCheck, PlayCircle, Search, XCircle } from 'lucide-react'; import { AlertTriangle, ArrowLeft, CheckCircle2, ChevronDown, ChevronUp, ClipboardList, Clock3, Download, PackageCheck, PlayCircle, Printer, Scissors, Search, Undo2, X, XCircle } 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, updateProductionOrderStatus } from '../dataService'; import { addProductionOrderDefect, consumeSupplyLotForProduction, exportToCSV, fetchProductionOrders, fetchProductionWorkstations, fetchSupplySummary, updateProductionOrderExecution, updateProductionOrderStatus } from '../dataService';
import type { DateRange, ProductionOrderItem, ProductionOrderStatus, ProductionOrderSummary, SupplyLot } from '../types'; import type { DateRange, ProductionOrderItem, ProductionOrderStatus, ProductionOrderSummary, ProductionWorkstation, SupplyLot } from '../types';
type ProductionOrderStatusTab = 'all' | 'open' | 'in_progress' | 'finished' | 'canceled'; type ProductionOrderStatusTab = 'all' | 'open' | 'cutting' | 'sewing' | 'review' | 'finished' | 'canceled';
const emptySummary: ProductionOrderSummary = { const emptySummary: ProductionOrderSummary = {
orders: [], orders: [],
counts: { all: 0, open: 0, in_progress: 0, finished: 0, canceled: 0 } counts: { all: 0, open: 0, cutting: 0, sewing: 0, review: 0, finished: 0, canceled: 0 }
}; };
const statusTabs: Array<{ key: ProductionOrderStatusTab; label: string; dotClass: string }> = [ const statusTabs: Array<{ key: ProductionOrderStatusTab; label: string; dotClass: string }> = [
{ key: 'all', label: 'Todas', dotClass: 'bg-dark-muted' }, { key: 'all', label: 'Todas', dotClass: 'bg-dark-muted' },
{ key: 'open', label: 'Em aberto', dotClass: 'bg-amber-400' }, { key: 'open', label: 'Em aberto', dotClass: 'bg-amber-400' },
{ key: 'in_progress', label: 'Em andamento', dotClass: 'bg-sky-400' }, { key: 'cutting', label: 'Em corte', dotClass: 'bg-amber-300' },
{ key: 'sewing', label: 'Em costura', dotClass: 'bg-sky-400' },
{ key: 'review', label: 'Em revisão', dotClass: 'bg-violet-400' },
{ key: 'finished', label: 'Finalizada', dotClass: 'bg-emerald-400' }, { key: 'finished', label: 'Finalizada', dotClass: 'bg-emerald-400' },
{ key: 'canceled', label: 'Cancelada', dotClass: 'bg-zinc-500' } { key: 'canceled', label: 'Cancelada', dotClass: 'bg-zinc-500' }
]; ];
const editableStatusOptions: Array<{ value: Exclude<ProductionOrderStatusTab, 'all'>; label: string; description: string }> = [ const editableStatusOptions: Array<{ value: Exclude<ProductionOrderStatusTab, 'all'>; label: string; description: string }> = [
{ value: 'open', label: 'Em aberto', description: 'Aguardando o início da produção.' }, { value: 'open', label: 'Em aberto', description: 'Aguardando o início da produção.' },
{ value: 'in_progress', label: 'Em andamento', description: 'A produção já foi iniciada.' }, { value: 'cutting', label: 'Em corte', description: 'Os materiais estão sendo transformados em peças cortadas.' },
{ value: 'sewing', label: 'Em costura', description: 'As peças cortadas estão em montagem.' },
{ value: 'review', label: 'Em revisão', description: 'A qualidade e as perdas estão sendo conferidas.' },
{ value: 'finished', label: 'Finalizada', description: 'A produção desta OP foi concluída.' }, { value: 'finished', label: 'Finalizada', description: 'A produção desta OP foi concluída.' },
{ value: 'canceled', label: 'Cancelada', description: 'Esta OP não seguirá para produção.' } { value: 'canceled', label: 'Cancelada', description: 'Esta OP não seguirá para produção.' }
]; ];
@@ -35,11 +39,21 @@ const statusStyles: Record<string, { label: string; className: string; dotClass:
className: 'border-amber-400/35 bg-amber-400/10 text-amber-300', className: 'border-amber-400/35 bg-amber-400/10 text-amber-300',
dotClass: 'bg-amber-400' dotClass: 'bg-amber-400'
}, },
in_progress: { cutting: {
label: 'Em andamento', label: 'Em corte',
className: 'border-amber-300/35 bg-amber-300/10 text-amber-200',
dotClass: 'bg-amber-300'
},
sewing: {
label: 'Em costura',
className: 'border-sky-400/35 bg-sky-400/10 text-sky-300', className: 'border-sky-400/35 bg-sky-400/10 text-sky-300',
dotClass: 'bg-sky-400' dotClass: 'bg-sky-400'
}, },
review: {
label: 'Em revisão',
className: 'border-violet-400/35 bg-violet-400/10 text-violet-300',
dotClass: 'bg-violet-400'
},
finished: { finished: {
label: 'Finalizada', label: 'Finalizada',
className: 'border-emerald-400/35 bg-emerald-400/10 text-emerald-300', className: 'border-emerald-400/35 bg-emerald-400/10 text-emerald-300',
@@ -74,6 +88,31 @@ const getStatusStyle = (status: ProductionOrderStatus, fallbackLabel: string) =>
} }
); );
const getNextStage = (status: ProductionOrderStatus): { status: Exclude<ProductionOrderStatusTab, 'all'>; label: string; description: string } | null => {
switch (status) {
case 'open': return { status: 'cutting', label: 'Iniciar corte', description: 'Libera o apontamento de corte e a baixa física de materiais.' };
case 'cutting': return { status: 'sewing', label: 'Avançar para costura', description: 'Use quando o corte desta OP estiver concluído.' };
case 'sewing': return { status: 'review', label: 'Enviar para revisão', description: 'Use quando as peças estiverem prontas para a conferência final.' };
case 'review': return { status: 'finished', label: 'Finalizar OP', description: 'Confirme a quantidade aprovada antes de encerrar a ordem.' };
default: return null;
}
};
const getPreviousStage = (status: ProductionOrderStatus): { status: Exclude<ProductionOrderStatusTab, 'all'>; label: string; description: string } | null => {
switch (status) {
case 'cutting': return { status: 'open', label: 'Voltar para em aberto', description: 'Use quando a produção ainda não foi iniciada.' };
case 'sewing': return { status: 'cutting', label: 'Voltar para corte', description: 'Use quando esta OP precisa retornar à etapa de corte.' };
case 'review': return { status: 'sewing', label: 'Voltar para costura', description: 'Use quando a revisão identificar um ajuste na montagem.' };
case 'finished': return { status: 'review', label: 'Reabrir em revisão', description: 'Use para corrigir uma OP finalizada antes de encerrá-la novamente.' };
default: return null;
}
};
const canReturnToCutPlan = (order: ProductionOrderItem) => (
!['finished', 'canceled'].includes(order.status)
&& (order.integrationStatus || '').toLowerCase() === 'local'
);
const ProductionOrdersSkeleton = () => ( const ProductionOrdersSkeleton = () => (
<div className="space-y-6" aria-label="Carregando ordens de produção"> <div className="space-y-6" aria-label="Carregando ordens de produção">
<div className="grid grid-cols-1 gap-4 md:grid-cols-4"> <div className="grid grid-cols-1 gap-4 md:grid-cols-4">
@@ -121,13 +160,19 @@ const ProductionOrders = () => {
refreshInterval: number; refreshInterval: number;
setRefreshInterval: (interval: number) => void; setRefreshInterval: (interval: number) => void;
}>(); }>();
const navigate = useNavigate();
const [searchTerm, setSearchTerm] = useState(''); const [searchTerm, setSearchTerm] = useState('');
const [statusFilter, setStatusFilter] = useState<ProductionOrderStatusTab>('all'); const [statusFilter, setStatusFilter] = useState<ProductionOrderStatusTab>('all');
const [summary, setSummary] = useState<ProductionOrderSummary>(emptySummary); const [summary, setSummary] = useState<ProductionOrderSummary>(emptySummary);
const [supplyLots, setSupplyLots] = useState<SupplyLot[]>([]); const [supplyLots, setSupplyLots] = useState<SupplyLot[]>([]);
const [workstations, setWorkstations] = useState<ProductionWorkstation[]>([]);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [isSupplyBusy, setIsSupplyBusy] = useState(false); const [isSupplyBusy, setIsSupplyBusy] = useState(false);
const [busyExecutionOrderId, setBusyExecutionOrderId] = useState<number | null>(null);
const [supplyMessage, setSupplyMessage] = useState(''); const [supplyMessage, setSupplyMessage] = useState('');
const [executionMessage, setExecutionMessage] = useState('');
const [busyDefectOrderId, setBusyDefectOrderId] = useState<number | null>(null);
const [defectMessage, setDefectMessage] = useState('');
const [busyStatusOrderId, setBusyStatusOrderId] = useState<number | null>(null); const [busyStatusOrderId, setBusyStatusOrderId] = useState<number | null>(null);
const [statusMessage, setStatusMessage] = useState(''); const [statusMessage, setStatusMessage] = useState('');
const [selectedOrderId, setSelectedOrderId] = useState<number | null>(null); const [selectedOrderId, setSelectedOrderId] = useState<number | null>(null);
@@ -152,13 +197,15 @@ const ProductionOrders = () => {
const load = async () => { const load = async () => {
setIsLoading(true); setIsLoading(true);
const [nextSummary, nextSupplySummary] = await Promise.all([ const [nextSummary, nextSupplySummary, nextWorkstations] = await Promise.all([
fetchProductionOrders(dateRange, { search: searchTerm }), fetchProductionOrders(dateRange, { search: searchTerm }),
fetchSupplySummary() fetchSupplySummary(),
fetchProductionWorkstations()
]); ]);
if (isMounted) { if (isMounted) {
setSummary(nextSummary); setSummary(nextSummary);
setSupplyLots(nextSupplySummary.lots); setSupplyLots(nextSupplySummary.lots);
setWorkstations(nextWorkstations);
setIsLoading(false); setIsLoading(false);
} }
}; };
@@ -192,10 +239,13 @@ const ProductionOrders = () => {
const isRefreshing = isLoading && summary.orders.length > 0; const isRefreshing = isLoading && summary.orders.length > 0;
const openCount = summary.counts.open || 0; const openCount = summary.counts.open || 0;
const progressCount = summary.counts.in_progress || 0; const progressCount = (summary.counts.cutting || 0) + (summary.counts.sewing || 0) + (summary.counts.review || 0);
const finishedCount = summary.counts.finished || 0; const finishedCount = summary.counts.finished || 0;
const totalQuantity = summary.orders.reduce((total, order) => total + order.quantity, 0); const finishedQuantity = summary.orders.filter(order => order.status === 'finished').reduce((total, order) => total + (order.finishedQuantity ?? order.quantity), 0);
const selectedOrder = summary.orders.find(order => order.id === selectedOrderId) || null; const selectedOrder = summary.orders.find(order => order.id === selectedOrderId) || null;
const selectedOrderNextStage = selectedOrder ? getNextStage(selectedOrder.status) : null;
const selectedOrderPreviousStage = selectedOrder ? getPreviousStage(selectedOrder.status) : null;
const selectedOrderCanReturnToCutPlan = Boolean(selectedOrder && canReturnToCutPlan(selectedOrder));
const handleManualRefresh = () => { const handleManualRefresh = () => {
void loadProductionOrders({ force: true }); void loadProductionOrders({ force: true });
@@ -219,6 +269,25 @@ const ProductionOrders = () => {
} }
}; };
const returnOrderToCutPlan = async (order: ProductionOrderItem) => {
if (!canReturnToCutPlan(order)) return;
const orderNumber = order.number || `#${order.id}`;
if (!window.confirm(`Devolver a OP ${orderNumber} ao Plano de Corte?\n\nA OP será cancelada, preservando o histórico. A necessidade voltará para o Plano de Corte e poderá ser gerada novamente quando você decidir.`)) return;
setBusyStatusOrderId(order.id);
setStatusMessage('');
try {
await updateProductionOrderStatus(order.id, 'canceled');
setSelectedOrderId(null);
setStatusMessage(`OP ${orderNumber} devolvida ao Plano de Corte.`);
navigate('/supplies/cutting');
} catch (error) {
setStatusMessage(error instanceof Error ? error.message : 'Não foi possível devolver a OP ao Plano de Corte.');
} finally {
setBusyStatusOrderId(null);
}
};
const refreshSupplyLots = async () => { const refreshSupplyLots = async () => {
const supplySummary = await fetchSupplySummary(); const supplySummary = await fetchSupplySummary();
setSupplyLots(supplySummary.lots); setSupplyLots(supplySummary.lots);
@@ -250,6 +319,76 @@ const ProductionOrders = () => {
} }
}; };
const handleExecutionSave = async (event: FormEvent<HTMLFormElement>, order: ProductionOrderItem) => {
event.preventDefault();
const form = new FormData(event.currentTarget);
const parseOptionalQuantity = (name: string) => {
const raw = String(form.get(name) || '').trim();
return raw ? Number(raw.replace(',', '.')) : null;
};
setBusyExecutionOrderId(order.id);
setExecutionMessage('');
try {
await updateProductionOrderExecution(order.id, {
actualCutQuantity: parseOptionalQuantity('actualCutQuantity'),
rejectedQuantity: parseOptionalQuantity('rejectedQuantity'),
finishedQuantity: parseOptionalQuantity('finishedQuantity'),
qualityNotes: String(form.get('qualityNotes') || '').trim(),
markerReference: String(form.get('markerReference') || '').trim(),
});
const nextSummary = await fetchProductionOrders(dateRange, { search: searchTerm }, { force: true });
setSummary(nextSummary);
setExecutionMessage(`Execução da OP ${order.number || `#${order.id}`} registrada.`);
} catch (error) {
setExecutionMessage(error instanceof Error ? error.message : 'Não foi possível registrar a execução.');
} finally {
setBusyExecutionOrderId(null);
}
};
const handleDefectSave = async (event: FormEvent<HTMLFormElement>, order: ProductionOrderItem) => {
event.preventDefault();
const form = new FormData(event.currentTarget);
const quantity = Number(String(form.get('quantity') || '').replace(',', '.'));
const workstationId = Number(form.get('workstationId'));
if (!workstationId || !Number.isFinite(quantity) || quantity <= 0) return;
setBusyDefectOrderId(order.id);
setDefectMessage('');
try {
await addProductionOrderDefect(order.id, {
workstationId,
defectType: String(form.get('defectType') || '').trim(),
origin: String(form.get('origin') || '').trim(),
quantity,
lotCode: String(form.get('lotCode') || '').trim(),
notes: String(form.get('notes') || '').trim(),
});
event.currentTarget.reset();
const nextSummary = await fetchProductionOrders(dateRange, { search: searchTerm }, { force: true });
setSummary(nextSummary);
setDefectMessage(`Defeito registrado na OP ${order.number || `#${order.id}`}.`);
} catch (error) {
setDefectMessage(error instanceof Error ? error.message : 'Não foi possível registrar o defeito.');
} finally {
setBusyDefectOrderId(null);
}
};
const printProductionSheet = (order: ProductionOrderItem) => {
const printWindow = window.open('', '_blank', 'noopener,noreferrer');
if (!printWindow) {
setStatusMessage('Permita pop-ups para imprimir a ficha de produção.');
return;
}
const componentRows = order.components.map(component => `<tr><td>${component.componentName}</td><td>${formatQuantity(component.totalQuantity)} ${component.unit}</td></tr>`).join('') || '<tr><td colspan="2">Composição não sincronizada.</td></tr>';
printWindow.document.write(`<!doctype html><html lang="pt-BR"><head><meta charset="utf-8"><title>Ficha ${order.number || order.id}</title><style>body{font-family:Arial,sans-serif;color:#18181b;padding:32px}h1{font-size:24px;margin:0}h2{font-size:14px;text-transform:uppercase;letter-spacing:.08em;margin:28px 0 10px}.meta{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px;margin-top:20px}.box{border:1px solid #d4d4d8;border-radius:8px;padding:10px;font-size:13px}.label{display:block;color:#71717a;font-size:10px;font-weight:bold;text-transform:uppercase;margin-bottom:4px}table{border-collapse:collapse;width:100%;font-size:13px}th,td{border:1px solid #d4d4d8;padding:9px;text-align:left}th{background:#f4f4f5}.line{border-bottom:1px solid #a1a1aa;height:28px;margin-top:8px}</style></head><body><h1>Ficha de Produção · ${order.number || `OP #${order.id}`}</h1><p>${order.productDescription}</p><div class="meta"><div class="box"><span class="label">SKU</span>${order.productSku || '-'}</div><div class="box"><span class="label">Quantidade planejada</span>${formatQuantity(order.quantity)} ${order.unit}</div><div class="box"><span class="label">Status</span>${order.statusLabel}</div><div class="box"><span class="label">Referência</span>${order.orderReference || '-'}</div></div><h2>Composição prevista</h2><table><thead><tr><th>Material</th><th>Quantidade</th></tr></thead><tbody>${componentRows}</tbody></table><h2>Apontamento de execução</h2><div class="meta"><div class="box"><span class="label">Quantidade cortada</span><div class="line"></div></div><div class="box"><span class="label">Perdas / defeitos</span><div class="line"></div></div><div class="box"><span class="label">Quantidade aprovada</span><div class="line"></div></div><div class="box"><span class="label">Responsável</span><div class="line"></div></div></div><h2>Observações</h2><div class="line"></div><div class="line"></div></body></html>`);
printWindow.document.close();
printWindow.focus();
printWindow.print();
};
const handleExport = () => { const handleExport = () => {
const exportData = filteredOrders.map(order => ({ const exportData = filteredOrders.map(order => ({
'Numero': order.number, 'Numero': order.number,
@@ -267,20 +406,20 @@ const ProductionOrders = () => {
exportToCSV(exportData, `ordens_producao_${new Date().toISOString().split('T')[0]}.csv`); exportToCSV(exportData, `ordens_producao_${new Date().toISOString().split('T')[0]}.csv`);
}; };
const renderOrderDetails = (order: ProductionOrderItem) => ( const renderOrderDetails = (order: ProductionOrderItem) => {
<div className="space-y-6 rounded-xl border border-dark-border bg-dark-input/25 p-5"> const canRecordOperationalData = ['cutting', 'sewing', 'review'].includes(order.status);
<div>
<p className="text-xs font-bold uppercase tracking-widest text-dark-muted">Detalhes da OP</p>
<p className="mt-1 text-sm font-bold text-dark-text">{order.number || `OP #${order.id}`} · {order.productDescription}</p>
</div>
return (
<div className="space-y-6">
<div className="grid grid-cols-2 gap-3 md:grid-cols-4"> <div className="grid grid-cols-2 gap-3 md:grid-cols-4">
<div className="rounded-xl border border-dark-border bg-dark-card p-3"><p className="text-[10px] font-bold uppercase tracking-wider text-dark-muted">Quantidade</p><p className="mt-1 text-lg font-bold text-dark-text">{formatQuantity(order.quantity)} <span className="text-xs text-dark-muted">{order.unit}</span></p></div> <div className="rounded-xl border border-dark-border bg-dark-card p-3"><p className="text-[10px] font-bold uppercase tracking-wider text-dark-muted">Quantidade</p><p className="mt-1 text-lg font-bold text-dark-text">{formatQuantity(order.quantity)} <span className="text-xs text-dark-muted">{order.unit}</span></p></div>
<div className="relative rounded-xl border border-dark-border bg-dark-card p-3"><p className="text-[10px] font-bold uppercase tracking-wider text-dark-muted">Status</p><button type="button" onClick={() => setStatusEditorOrderId(current => current === order.id ? null : order.id)} aria-expanded={statusEditorOrderId === order.id} className={`mt-2 inline-flex cursor-pointer items-center gap-2 rounded-lg border px-2.5 py-1 text-xs font-bold ${getStatusStyle(order.status, order.statusLabel).className}`}><span className={`h-2 w-2 rounded-full ${getStatusStyle(order.status, order.statusLabel).dotClass}`} />{getStatusStyle(order.status, order.statusLabel).label}{statusEditorOrderId === order.id ? <ChevronUp className="h-3.5 w-3.5" /> : <ChevronDown className="h-3.5 w-3.5" />}</button>{statusEditorOrderId === order.id && <div className="absolute left-0 top-full z-20 mt-2 w-64 overflow-hidden rounded-xl border border-dark-border bg-dark-card shadow-xl"><p className="border-b border-dark-border px-3 py-2 text-[10px] font-bold uppercase tracking-wider text-dark-muted">Alterar status</p>{editableStatusOptions.map(option => { const style = getStatusStyle(option.value, option.label); const isCurrent = order.status === option.value; const Icon = option.value === 'finished' ? CheckCircle2 : option.value === 'canceled' ? XCircle : option.value === 'in_progress' ? PlayCircle : Clock3; return <button key={option.value} type="button" disabled={isCurrent || busyStatusOrderId === order.id} onClick={() => void handleStatusChange(order, option.value)} className={`flex w-full cursor-pointer items-center gap-3 px-3 py-2.5 text-left transition-colors disabled:cursor-not-allowed disabled:opacity-60 ${isCurrent ? style.className : 'hover:bg-dark-input/70'}`}><Icon className="h-4 w-4 shrink-0" /><span className="text-xs font-bold text-dark-text">{option.label}{isCurrent && ' · Atual'}</span></button>; })}</div>}</div> <div className="relative rounded-xl border border-dark-border bg-dark-card p-3"><p className="text-[10px] font-bold uppercase tracking-wider text-dark-muted">Status</p><button type="button" onClick={() => setStatusEditorOrderId(current => current === order.id ? null : order.id)} aria-expanded={statusEditorOrderId === order.id} className={`mt-2 inline-flex cursor-pointer items-center gap-2 rounded-lg border px-2.5 py-1 text-xs font-bold ${getStatusStyle(order.status, order.statusLabel).className}`}><span className={`h-2 w-2 rounded-full ${getStatusStyle(order.status, order.statusLabel).dotClass}`} />{getStatusStyle(order.status, order.statusLabel).label}{statusEditorOrderId === order.id ? <ChevronUp className="h-3.5 w-3.5" /> : <ChevronDown className="h-3.5 w-3.5" />}</button>{statusEditorOrderId === order.id && <div className="absolute left-0 top-full z-20 mt-2 w-64 overflow-hidden rounded-xl border border-dark-border bg-dark-card shadow-xl"><p className="border-b border-dark-border px-3 py-2 text-[10px] font-bold uppercase tracking-wider text-dark-muted">Alterar status</p>{editableStatusOptions.map(option => { const style = getStatusStyle(option.value, option.label); const isCurrent = order.status === option.value; const Icon = option.value === 'finished' ? CheckCircle2 : option.value === 'canceled' ? XCircle : option.value === 'cutting' ? Scissors : option.value === 'sewing' ? PlayCircle : option.value === 'review' ? ClipboardList : Clock3; return <button key={option.value} type="button" disabled={isCurrent || busyStatusOrderId === order.id} onClick={() => void handleStatusChange(order, option.value)} className={`flex w-full cursor-pointer items-center gap-3 px-3 py-2.5 text-left transition-colors disabled:cursor-not-allowed disabled:opacity-60 ${isCurrent ? style.className : 'hover:bg-dark-input/70'}`}><Icon className="h-4 w-4 shrink-0" /><span className="text-xs font-bold text-dark-text">{option.label}{isCurrent && ' · Atual'}</span></button>; })}</div>}</div>
<div className="rounded-xl border border-dark-border bg-dark-card p-3"><p className="text-[10px] font-bold uppercase tracking-wider text-dark-muted">Emissão</p><p className="mt-1 text-sm font-bold text-dark-text">{formatDate(order.issueDate)}</p></div> <div className="rounded-xl border border-dark-border bg-dark-card p-3"><p className="text-[10px] font-bold uppercase tracking-wider text-dark-muted">Emissão</p><p className="mt-1 text-sm font-bold text-dark-text">{formatDate(order.issueDate)}</p></div>
<div className="rounded-xl border border-dark-border bg-dark-card p-3"><p className="text-[10px] font-bold uppercase tracking-wider text-dark-muted">Previsão</p><p className="mt-1 text-sm font-bold text-dark-text">{formatDate(order.expectedDate)}</p></div> <div className="rounded-xl border border-dark-border bg-dark-card p-3"><p className="text-[10px] font-bold uppercase tracking-wider text-dark-muted">Previsão</p><p className="mt-1 text-sm font-bold text-dark-text">{formatDate(order.expectedDate)}</p></div>
</div> </div>
{order.status === 'finished' && <section><h3 className="text-xs font-bold uppercase tracking-widest text-dark-muted">Resultado registrado</h3><div className="mt-3 grid gap-3 sm:grid-cols-3"><div className="rounded-xl border border-dark-border bg-dark-card p-3"><p className="text-[10px] font-bold uppercase tracking-wider text-dark-muted">Cortado</p><p className="mt-1 text-lg font-bold text-dark-text">{formatQuantity(order.actualCutQuantity ?? 0)} <span className="text-xs text-dark-muted">{order.unit}</span></p></div><div className="rounded-xl border border-dark-border bg-dark-card p-3"><p className="text-[10px] font-bold uppercase tracking-wider text-dark-muted">Perdas</p><p className="mt-1 text-lg font-bold text-amber-200">{formatQuantity(order.rejectedQuantity || 0)} <span className="text-xs text-dark-muted">{order.unit}</span></p></div><div className="rounded-xl border border-dark-border bg-dark-card p-3"><p className="text-[10px] font-bold uppercase tracking-wider text-dark-muted">Aprovado</p><p className="mt-1 text-lg font-bold text-emerald-300">{formatQuantity(order.finishedQuantity ?? 0)} <span className="text-xs text-dark-muted">{order.unit}</span></p></div></div>{(order.markerReference || order.qualityNotes) && <div className="mt-3 rounded-xl border border-dark-border bg-dark-card p-3 text-xs font-semibold text-dark-muted">{order.markerReference && <p>Referência do risco: <span className="font-bold text-dark-text">{order.markerReference}</span></p>}{order.qualityNotes && <p className={order.markerReference ? 'mt-2' : ''}>{order.qualityNotes}</p>}</div>}</section>}
<section> <section>
<h3 className="text-xs font-bold uppercase tracking-widest text-dark-muted">Produto e marcadores</h3> <h3 className="text-xs font-bold uppercase tracking-widest text-dark-muted">Produto e marcadores</h3>
<div className="mt-3 rounded-xl border border-dark-border bg-dark-card p-4"> <div className="mt-3 rounded-xl border border-dark-border bg-dark-card p-4">
@@ -290,19 +429,61 @@ const ProductionOrders = () => {
</div> </div>
</section> </section>
{canRecordOperationalData && <section>
<div><h3 className="text-xs font-bold uppercase tracking-widest text-dark-muted">Execução e qualidade</h3><p className="mt-1 text-xs font-semibold text-dark-muted">Registre o corte real, perdas e a quantidade aprovada antes de avançar a OP.</p></div>
<form onSubmit={(event) => void handleExecutionSave(event, order)} className="mt-3 rounded-xl border border-dark-border bg-dark-card p-4">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
<label className="text-xs font-bold text-dark-muted">Cortado real<input name="actualCutQuantity" inputMode="decimal" defaultValue={order.actualCutQuantity ?? ''} className="mt-1.5 h-11 w-full rounded-lg border border-dark-border bg-dark-input px-3 text-sm font-semibold text-dark-text outline-none placeholder:text-dark-muted focus:border-brand-primary" placeholder={formatQuantity(order.quantity)} /></label>
<label className="text-xs font-bold text-dark-muted">Perdas / defeitos<input name="rejectedQuantity" inputMode="decimal" defaultValue={order.rejectedQuantity || ''} className="mt-1.5 h-11 w-full rounded-lg border border-dark-border bg-dark-input px-3 text-sm font-semibold text-dark-text outline-none placeholder:text-dark-muted focus:border-brand-primary" placeholder="0" /></label>
<label className="text-xs font-bold text-dark-muted">Aprovado final<input name="finishedQuantity" inputMode="decimal" defaultValue={order.finishedQuantity ?? ''} className="mt-1.5 h-11 w-full rounded-lg border border-dark-border bg-dark-input px-3 text-sm font-semibold text-dark-text outline-none placeholder:text-dark-muted focus:border-brand-primary" placeholder="Após revisão" /></label>
<label className="text-xs font-bold text-dark-muted sm:col-span-2">Referência do risco<input name="markerReference" defaultValue={order.markerReference} className="mt-1.5 h-11 w-full rounded-lg border border-dark-border bg-dark-input px-3 text-sm font-semibold text-dark-text outline-none placeholder:text-dark-muted focus:border-brand-primary" placeholder="Código ou arquivo do risco" /></label>
<label className="text-xs font-bold text-dark-muted sm:col-span-3">Motivo de perda / observação<textarea name="qualityNotes" defaultValue={order.qualityNotes} className="mt-1.5 min-h-20 w-full rounded-lg border border-dark-border bg-dark-input px-3 py-2.5 text-sm font-semibold text-dark-text outline-none placeholder:text-dark-muted focus:border-brand-primary" placeholder="Ex.: 4 peças com mancha de tinta; 2 recuperadas na revisão." /></label>
</div>
<div className="mt-4 flex flex-wrap items-center justify-between gap-3"><p className="text-xs font-semibold text-dark-muted">Planejado: {formatQuantity(order.quantity)} {order.unit}</p><button type="submit" disabled={busyExecutionOrderId === order.id} className="inline-flex h-10 items-center gap-2 rounded-lg border border-brand-primary/35 bg-brand-primary/10 px-4 text-sm font-bold text-brand-primary transition-colors hover:bg-brand-primary/20 disabled:cursor-not-allowed disabled:opacity-50 cursor-pointer"><CheckCircle2 className="h-4 w-4" />{busyExecutionOrderId === order.id ? 'Salvando...' : 'Salvar execução'}</button></div>
{executionMessage && <p className="mt-3 rounded-lg border border-dark-border bg-dark-input px-3 py-2 text-sm font-bold text-dark-muted">{executionMessage}</p>}
</form>
</section>}
{(canRecordOperationalData || order.defects.length > 0) && <section>
<div>
<h3 className="text-xs font-bold uppercase tracking-widest text-dark-muted">Defeitos e origem</h3>
<p className="mt-1 text-xs font-semibold text-dark-muted">Registre cada ocorrência para acompanhar perdas por posto, lote e tipo de defeito.</p>
</div>
{canRecordOperationalData && <form onSubmit={(event) => void handleDefectSave(event, order)} className="mt-3 rounded-xl border border-dark-border bg-dark-card p-4">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
<label className="text-xs font-bold text-dark-muted">Posto de trabalho<select required name="workstationId" defaultValue="" className="mt-1.5 h-11 w-full rounded-lg border border-dark-border bg-dark-input px-3 text-sm font-semibold text-dark-text outline-none focus:border-brand-primary"><option value="">Selecione...</option>{workstations.map(workstation => <option key={workstation.id} value={workstation.id}>{workstation.name}</option>)}</select></label>
<label className="text-xs font-bold text-dark-muted">Tipo de defeito<input required name="defectType" className="mt-1.5 h-11 w-full rounded-lg border border-dark-border bg-dark-input px-3 text-sm font-semibold text-dark-text outline-none placeholder:text-dark-muted focus:border-brand-primary" placeholder="Ex.: furo, mancha" /></label>
<label className="text-xs font-bold text-dark-muted">Quantidade<input required name="quantity" inputMode="decimal" className="mt-1.5 h-11 w-full rounded-lg border border-dark-border bg-dark-input px-3 text-sm font-semibold text-dark-text outline-none placeholder:text-dark-muted focus:border-brand-primary" placeholder="0" /></label>
<label className="text-xs font-bold text-dark-muted">Lote (opcional)<input name="lotCode" defaultValue={order.lotCode} className="mt-1.5 h-11 w-full rounded-lg border border-dark-border bg-dark-input px-3 text-sm font-semibold text-dark-text outline-none placeholder:text-dark-muted focus:border-brand-primary" placeholder="Código do lote" /></label>
<label className="text-xs font-bold text-dark-muted sm:col-span-2">Origem (opcional)<input name="origin" className="mt-1.5 h-11 w-full rounded-lg border border-dark-border bg-dark-input px-3 text-sm font-semibold text-dark-text outline-none placeholder:text-dark-muted focus:border-brand-primary" placeholder="Ex.: recebimento, corte, costura" /></label>
<label className="text-xs font-bold text-dark-muted sm:col-span-2">Observação (opcional)<input name="notes" className="mt-1.5 h-11 w-full rounded-lg border border-dark-border bg-dark-input px-3 text-sm font-semibold text-dark-text outline-none placeholder:text-dark-muted focus:border-brand-primary" placeholder="Detalhe necessário para a revisão" /></label>
</div>
<div className="mt-4 flex flex-wrap items-center justify-between gap-3"><p className="text-xs font-semibold text-dark-muted">{workstations.length ? `${workstations.length} postos ativos disponíveis.` : 'Nenhum posto de trabalho ativo cadastrado.'}</p><button type="submit" disabled={busyDefectOrderId === order.id || !workstations.length} className="inline-flex h-10 items-center gap-2 rounded-lg border border-amber-400/35 bg-amber-400/10 px-4 text-sm font-bold text-amber-200 transition-colors hover:bg-amber-400/20 disabled:cursor-not-allowed disabled:opacity-50 cursor-pointer"><AlertTriangle className="h-4 w-4" />{busyDefectOrderId === order.id ? 'Registrando...' : 'Registrar defeito'}</button></div>
{defectMessage && <p className="mt-3 rounded-lg border border-dark-border bg-dark-input px-3 py-2 text-sm font-bold text-dark-muted">{defectMessage}</p>}
</form>}
{order.defects.length > 0 && <div className="mt-3 overflow-hidden rounded-xl border border-dark-border bg-dark-card"><div className="grid grid-cols-[minmax(0,1fr)_auto] gap-3 border-b border-dark-border bg-dark-input/40 px-4 py-2.5 text-[10px] font-bold uppercase tracking-widest text-dark-muted"><span>Ocorrência</span><span>Quantidade</span></div><div className="divide-y divide-dark-border">{order.defects.map(defect => <div key={defect.id} className="grid grid-cols-[minmax(0,1fr)_auto] gap-3 px-4 py-3"><div><p className="text-sm font-bold text-dark-text">{defect.defectType}</p><p className="mt-1 text-xs font-semibold text-dark-muted">{[defect.workstationName || 'Sem posto', defect.origin, defect.lotCode ? `Lote ${defect.lotCode}` : ''].filter(Boolean).join(' · ')}</p>{defect.notes && <p className="mt-1 text-xs font-medium text-dark-muted">{defect.notes}</p>}</div><p className="text-sm font-bold text-amber-200">{formatQuantity(defect.quantity)} {order.unit}</p></div>)}</div></div>}
{!order.defects.length && canRecordOperationalData && <p className="mt-3 rounded-xl border border-dark-border bg-dark-card px-4 py-3 text-sm font-semibold text-dark-muted">Nenhum defeito registrado nesta OP.</p>}
</section>}
<section> <section>
<h3 className="text-xs font-bold uppercase tracking-widest text-dark-muted">Composição</h3> <h3 className="text-xs font-bold uppercase tracking-widest text-dark-muted">Composição</h3>
{order.components.length ? <div className="mt-3 overflow-hidden rounded-xl border border-dark-border"><table className="w-full text-xs"><thead className="bg-dark-header text-dark-muted"><tr><th className="px-3 py-2.5 text-left font-bold">Material</th><th className="px-3 py-2.5 text-right font-bold">Por unidade</th><th className="px-3 py-2.5 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-3"><p className="font-bold text-dark-text">{component.componentName}</p><p className="mt-0.5 font-mono text-[10px] font-semibold text-dark-muted">{component.componentSku || 'Sem SKU'}</p></td><td className="px-3 py-3 text-right font-semibold text-dark-muted">{formatQuantity(component.quantityPerUnit)} {component.unit}</td><td className="px-3 py-3 text-right font-bold text-dark-text">{formatQuantity(component.totalQuantity)} {component.unit}</td></tr>)}</tbody></table></div> : <p className="mt-3 rounded-xl border border-dark-border bg-dark-card px-4 py-3 text-sm font-semibold text-dark-muted">Sem composição sincronizada.</p>} {order.components.length ? <div className="mt-3 overflow-hidden rounded-xl border border-dark-border"><table className="w-full text-xs"><thead className="bg-dark-header text-dark-muted"><tr><th className="px-3 py-2.5 text-left font-bold">Material</th><th className="px-3 py-2.5 text-right font-bold">Por unidade</th><th className="px-3 py-2.5 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-3"><p className="font-bold text-dark-text">{component.componentName}</p><p className="mt-0.5 font-mono text-[10px] font-semibold text-dark-muted">{component.componentSku || 'Sem SKU'}</p></td><td className="px-3 py-3 text-right font-semibold text-dark-muted">{formatQuantity(component.quantityPerUnit)} {component.unit}</td><td className="px-3 py-3 text-right font-bold text-dark-text">{formatQuantity(component.totalQuantity)} {component.unit}</td></tr>)}</tbody></table></div> : <p className="mt-3 rounded-xl border border-dark-border bg-dark-card px-4 py-3 text-sm font-semibold text-dark-muted">Sem composição sincronizada.</p>}
</section> </section>
<section> {order.lotReservations.length > 0 && <section>
<h3 className="text-xs font-bold uppercase tracking-widest text-dark-muted">Lotes reservados pelo plano</h3>
<div className="mt-3 overflow-hidden rounded-xl border border-dark-border"><table className="w-full text-xs"><thead className="bg-dark-header text-dark-muted"><tr><th className="px-3 py-2.5 text-left font-bold">Lote</th><th className="px-3 py-2.5 text-left font-bold">Material</th><th className="px-3 py-2.5 text-right font-bold">Reservado</th><th className="px-3 py-2.5 text-right font-bold">Consumido</th></tr></thead><tbody className="divide-y divide-dark-border">{order.lotReservations.map(reservation => <tr key={reservation.id}><td className="px-3 py-3"><p className="font-bold text-dark-text">#{reservation.lotId}</p><p className="mt-0.5 text-[10px] font-semibold text-dark-muted">{reservation.lotSupplier || 'Sem fornecedor'}</p></td><td className="px-3 py-3"><p className="font-bold text-dark-text">{reservation.lotProduct}</p><p className="mt-0.5 font-mono text-[10px] font-semibold text-dark-muted">{reservation.materialKey}</p></td><td className="px-3 py-3 text-right font-bold text-brand-primary">{formatQuantity(reservation.quantity)} {reservation.lotUnit}</td><td className="px-3 py-3 text-right font-semibold text-dark-muted">{formatQuantity(reservation.consumedQuantity)} {reservation.lotUnit}</td></tr>)}</tbody></table></div>
</section>}
{order.status === 'cutting' && <section>
<h3 className="text-xs font-bold uppercase tracking-widest text-dark-muted">Baixa de material</h3> <h3 className="text-xs font-bold uppercase tracking-widest text-dark-muted">Baixa de material</h3>
{order.status === 'in_progress' ? <form onSubmit={handleProductionExit} className="mt-3 rounded-xl border border-dark-border bg-dark-card p-4"><p className="text-sm font-semibold text-dark-muted">Registre uma saída somente quando o material for realmente consumido pela OP.</p><div className="mt-4 grid grid-cols-1 gap-3 sm:grid-cols-2"><label className="text-xs font-bold text-dark-muted">Lote de material<select value={exitForm.lotId} onChange={(event) => setExitForm(current => ({ ...current, lotId: event.target.value }))} className="mt-1.5 h-11 w-full rounded-lg border border-dark-border bg-dark-input px-3 text-sm font-semibold text-dark-text outline-none focus:border-brand-primary"><option value="">Selecione...</option>{supplyLots.map(lot => <option key={lot.id} value={lot.id}>#{lot.id} · {lot.product} · {formatQuantity(lot.quantity)} {lot.unit}</option>)}</select></label><label className="text-xs font-bold text-dark-muted">Quantidade<input inputMode="decimal" value={exitForm.quantity} onChange={(event) => setExitForm(current => ({ ...current, quantity: event.target.value }))} className="mt-1.5 h-11 w-full rounded-lg border border-dark-border bg-dark-input px-3 text-sm font-semibold text-dark-text outline-none placeholder:text-dark-muted focus:border-brand-primary" placeholder="kg" /></label><label className="sm:col-span-2 text-xs font-bold text-dark-muted">Motivo (opcional)<input value={exitForm.reason} onChange={(event) => setExitForm(current => ({ ...current, reason: event.target.value }))} className="mt-1.5 h-11 w-full rounded-lg border border-dark-border bg-dark-input px-3 text-sm font-semibold text-dark-text outline-none placeholder:text-dark-muted focus:border-brand-primary" placeholder="Ex.: corte do pedido" /></label></div>{selectedLot && <p className="mt-3 text-xs font-bold text-dark-muted">Saldo do lote #{selectedLot.id}: {formatQuantity(selectedLot.quantity)} {selectedLot.unit}</p>}<button type="submit" disabled={isSupplyBusy || !supplyLots.length || !exitForm.lotId || !exitForm.quantity} className="mt-4 inline-flex h-11 items-center gap-2 rounded-lg border border-brand-primary/35 bg-brand-primary/10 px-4 text-sm font-bold text-brand-primary transition-colors hover:bg-brand-primary/20 disabled:cursor-not-allowed disabled:opacity-50"><PackageCheck className="h-4 w-4" />{isSupplyBusy ? 'Registrando...' : 'Registrar baixa'}</button>{supplyMessage && <p className="mt-3 rounded-lg border border-dark-border bg-dark-input px-3 py-2 text-sm font-bold text-dark-muted">{supplyMessage}</p>}</form> : <div className="mt-3 rounded-xl border border-dark-border bg-dark-card px-4 py-3 text-sm font-semibold text-dark-muted">Altere a OP para <span className="font-bold text-sky-300">Em andamento</span> antes de registrar a baixa de materiais.</div>} {order.status === 'cutting' ? <form onSubmit={handleProductionExit} className="mt-3 rounded-xl border border-dark-border bg-dark-card p-4"><p className="text-sm font-semibold text-dark-muted">Registre uma saída somente quando o material for realmente consumido pela OP.</p><div className="mt-4 grid grid-cols-1 gap-3 sm:grid-cols-2"><label className="text-xs font-bold text-dark-muted">Lote de material<select value={exitForm.lotId} onChange={(event) => setExitForm(current => ({ ...current, lotId: event.target.value }))} className="mt-1.5 h-11 w-full rounded-lg border border-dark-border bg-dark-input px-3 text-sm font-semibold text-dark-text outline-none focus:border-brand-primary"><option value="">Selecione...</option>{supplyLots.map(lot => <option key={lot.id} value={lot.id}>#{lot.id} · {lot.product} · {formatQuantity(lot.quantity)} {lot.unit}</option>)}</select></label><label className="text-xs font-bold text-dark-muted">Quantidade<input inputMode="decimal" value={exitForm.quantity} onChange={(event) => setExitForm(current => ({ ...current, quantity: event.target.value }))} className="mt-1.5 h-11 w-full rounded-lg border border-dark-border bg-dark-input px-3 text-sm font-semibold text-dark-text outline-none placeholder:text-dark-muted focus:border-brand-primary" placeholder="kg" /></label><label className="sm:col-span-2 text-xs font-bold text-dark-muted">Motivo (opcional)<input value={exitForm.reason} onChange={(event) => setExitForm(current => ({ ...current, reason: event.target.value }))} className="mt-1.5 h-11 w-full rounded-lg border border-dark-border bg-dark-input px-3 text-sm font-semibold text-dark-text outline-none placeholder:text-dark-muted focus:border-brand-primary" placeholder="Ex.: corte do pedido" /></label></div>{selectedLot && <p className="mt-3 text-xs font-bold text-dark-muted">Saldo do lote #{selectedLot.id}: {formatQuantity(selectedLot.quantity)} {selectedLot.unit}</p>}<button type="submit" disabled={isSupplyBusy || !supplyLots.length || !exitForm.lotId || !exitForm.quantity} className="mt-4 inline-flex h-11 items-center gap-2 rounded-lg border border-brand-primary/35 bg-brand-primary/10 px-4 text-sm font-bold text-brand-primary transition-colors hover:bg-brand-primary/20 disabled:cursor-not-allowed disabled:opacity-50"><PackageCheck className="h-4 w-4" />{isSupplyBusy ? 'Registrando...' : 'Registrar baixa'}</button>{supplyMessage && <p className="mt-3 rounded-lg border border-dark-border bg-dark-input px-3 py-2 text-sm font-bold text-dark-muted">{supplyMessage}</p>}</form> : <div className="mt-3 rounded-xl border border-dark-border bg-dark-card px-4 py-3 text-sm font-semibold text-dark-muted">Altere a OP para <span className="font-bold text-amber-200">Em corte</span> antes de registrar a baixa de materiais.</div>}
</section> </section>}
{(order.steps.length > 0 || order.notes || order.supplier || order.lotCode || order.rollQuantity || order.fabricKg || order.ribKg || order.yieldPiecesPerKg) && <section><h3 className="text-xs font-bold uppercase tracking-widest text-dark-muted">Informações adicionais</h3><div className="mt-3 grid grid-cols-1 gap-3 sm:grid-cols-2">{(order.supplier || order.lotCode || order.rollQuantity || order.fabricKg || order.ribKg || order.yieldPiecesPerKg) && <dl className="grid grid-cols-2 gap-3 rounded-xl border border-dark-border bg-dark-card p-4 text-xs sm:col-span-2">{order.supplier && <div><dt className="font-bold text-dark-muted">Fornecedor</dt><dd className="mt-1 font-bold text-dark-text">{order.supplier}</dd></div>}{order.lotCode && <div><dt className="font-bold text-dark-muted">Lote</dt><dd className="mt-1 font-bold text-dark-text">{order.lotCode}</dd></div>}{order.rollQuantity !== null && <div><dt className="font-bold text-dark-muted">Rolos</dt><dd className="mt-1 font-bold text-dark-text">{formatQuantity(order.rollQuantity)}</dd></div>}{order.fabricKg !== null && <div><dt className="font-bold text-dark-muted">Malha</dt><dd className="mt-1 font-bold text-dark-text">{formatQuantity(order.fabricKg)} kg</dd></div>}{order.ribKg !== null && <div><dt className="font-bold text-dark-muted">Ribana</dt><dd className="mt-1 font-bold text-dark-text">{formatQuantity(order.ribKg)} kg</dd></div>}{order.yieldPiecesPerKg !== null && <div><dt className="font-bold text-dark-muted">Rendimento</dt><dd className="mt-1 font-bold text-dark-text">{formatQuantity(order.yieldPiecesPerKg)} un./kg</dd></div>}</dl>}{order.steps.length > 0 && <div className="rounded-xl border border-dark-border bg-dark-card p-4 sm:col-span-2"><p className="text-xs font-bold text-dark-text">Etapas</p><div className="mt-3 divide-y divide-dark-border">{order.steps.map(step => <div key={step.id} className="flex items-center gap-3 py-2 text-xs"><span className="h-2.5 w-2.5 rounded-full" style={{ backgroundColor: step.color || 'var(--color-brand-primary)' }} /><span className="font-bold text-dark-text">{step.name}</span><span className="ml-auto font-semibold text-dark-muted">{formatDate(step.startDate)} - {formatDate(step.endDate)}</span></div>)}</div></div>}{order.notes && <p className="whitespace-pre-wrap rounded-xl border border-dark-border bg-dark-card p-4 text-sm font-semibold text-dark-muted sm:col-span-2">{order.notes}</p>}</div></section>} {(order.steps.length > 0 || order.notes || order.supplier || order.lotCode || order.rollQuantity || order.fabricKg || order.ribKg || order.yieldPiecesPerKg) && <section><h3 className="text-xs font-bold uppercase tracking-widest text-dark-muted">Informações adicionais</h3><div className="mt-3 grid grid-cols-1 gap-3 sm:grid-cols-2">{(order.supplier || order.lotCode || order.rollQuantity || order.fabricKg || order.ribKg || order.yieldPiecesPerKg) && <dl className="grid grid-cols-2 gap-3 rounded-xl border border-dark-border bg-dark-card p-4 text-xs sm:col-span-2">{order.supplier && <div><dt className="font-bold text-dark-muted">Fornecedor</dt><dd className="mt-1 font-bold text-dark-text">{order.supplier}</dd></div>}{order.lotCode && <div><dt className="font-bold text-dark-muted">Lote</dt><dd className="mt-1 font-bold text-dark-text">{order.lotCode}</dd></div>}{order.rollQuantity !== null && <div><dt className="font-bold text-dark-muted">Rolos</dt><dd className="mt-1 font-bold text-dark-text">{formatQuantity(order.rollQuantity)}</dd></div>}{order.fabricKg !== null && <div><dt className="font-bold text-dark-muted">Malha</dt><dd className="mt-1 font-bold text-dark-text">{formatQuantity(order.fabricKg)} kg</dd></div>}{order.ribKg !== null && <div><dt className="font-bold text-dark-muted">Ribana</dt><dd className="mt-1 font-bold text-dark-text">{formatQuantity(order.ribKg)} kg</dd></div>}{order.yieldPiecesPerKg !== null && <div><dt className="font-bold text-dark-muted">Rendimento</dt><dd className="mt-1 font-bold text-dark-text">{formatQuantity(order.yieldPiecesPerKg)} un./kg</dd></div>}</dl>}{order.steps.length > 0 && <div className="rounded-xl border border-dark-border bg-dark-card p-4 sm:col-span-2"><p className="text-xs font-bold text-dark-text">Etapas</p><div className="mt-3 divide-y divide-dark-border">{order.steps.map(step => <div key={step.id} className="flex items-center gap-3 py-2 text-xs"><span className="h-2.5 w-2.5 rounded-full" style={{ backgroundColor: step.color || 'var(--color-brand-primary)' }} /><span className="font-bold text-dark-text">{step.name}</span><span className="ml-auto font-semibold text-dark-muted">{formatDate(step.startDate)} - {formatDate(step.endDate)}</span></div>)}</div></div>}{order.notes && <p className="whitespace-pre-wrap rounded-xl border border-dark-border bg-dark-card p-4 text-sm font-semibold text-dark-muted sm:col-span-2">{order.notes}</p>}</div></section>}
</div> </div>
); );
};
return ( return (
<div className="space-y-6"> <div className="space-y-6">
@@ -313,7 +494,8 @@ 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 locais geradas pelo plano de corte.</p> <p className="mt-2 text-dark-muted font-medium">Acompanhe a fila de produção e o histórico das ordens.</p>
<div className="mt-3 flex flex-wrap gap-x-4 gap-y-2"><Link to="/production-plans" className="inline-flex items-center gap-2 text-sm font-bold text-brand-primary transition-colors hover:text-brand-primary/80"><ClipboardList className="h-4 w-4" />Ver planos de produção</Link><Link to="/production-quality" className="inline-flex items-center gap-2 text-sm font-bold text-brand-primary transition-colors hover:text-brand-primary/80"><AlertTriangle className="h-4 w-4" />Ver qualidade</Link></div>
</div> </div>
<DateRangePicker <DateRangePicker
dateRange={dateRange} dateRange={dateRange}
@@ -375,7 +557,7 @@ const ProductionOrders = () => {
<div> <div>
<p className="text-xs font-bold uppercase tracking-widest text-dark-muted">Finalizadas</p> <p className="text-xs font-bold uppercase tracking-widest text-dark-muted">Finalizadas</p>
<p className="mt-2 text-3xl font-bold text-emerald-300">{finishedCount}</p> <p className="mt-2 text-3xl font-bold text-emerald-300">{finishedCount}</p>
<p className="mt-1 text-xs font-semibold text-dark-muted">{formatQuantity(totalQuantity)} un. no período</p> <p className="mt-1 text-xs font-semibold text-dark-muted">{formatQuantity(finishedQuantity)} un. aprovadas</p>
</div> </div>
<div className="rounded-xl border border-emerald-400/25 bg-emerald-400/10 p-3 text-emerald-300"> <div className="rounded-xl border border-emerald-400/25 bg-emerald-400/10 p-3 text-emerald-300">
<CheckCircle2 className="h-5 w-5" /> <CheckCircle2 className="h-5 w-5" />
@@ -492,14 +674,14 @@ const ProductionOrders = () => {
{paginatedOrders.map((order: ProductionOrderItem) => { {paginatedOrders.map((order: ProductionOrderItem) => {
const statusStyle = getStatusStyle(order.status, order.statusLabel); const statusStyle = getStatusStyle(order.status, order.statusLabel);
return ( return (
<Fragment key={order.id}> <tr key={order.id} className="transition-colors hover:bg-dark-input/50">
<tr className="transition-colors hover:bg-dark-input/50">
<td className="px-6 py-3 font-mono text-xs font-bold text-dark-text">{order.number || '-'}</td> <td className="px-6 py-3 font-mono text-xs font-bold text-dark-text">{order.number || '-'}</td>
<td className="px-6 py-3"> <td className="px-6 py-3">
<div className="font-bold text-dark-text">{order.productDescription}</div> <div className="font-bold text-dark-text">{order.productDescription}</div>
<div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-[10px] font-semibold text-dark-muted"> <div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-[10px] font-semibold text-dark-muted">
<span className="font-mono">{order.productSku || 'Sem SKU'}</span> <span className="font-mono">{order.productSku || 'Sem SKU'}</span>
<span>{formatDate(order.issueDate)}</span> <span>{formatDate(order.issueDate)}</span>
{order.productionPlanNumber && <Link to="/production-plans" className="font-mono text-brand-primary hover:underline">{order.productionPlanNumber}</Link>}
{order.orderReference && <span>{order.orderReference}</span>} {order.orderReference && <span>{order.orderReference}</span>}
</div> </div>
</td> </td>
@@ -516,22 +698,20 @@ const ProductionOrders = () => {
<td className="px-6 py-3 text-right"> <td className="px-6 py-3 text-right">
<button <button
type="button" type="button"
onClick={() => { onClick={() => {
setSelectedOrderId(current => current === order.id ? null : order.id); setSelectedOrderId(order.id);
setSupplyMessage(''); setSupplyMessage('');
setExitForm({ lotId: '', quantity: '', reason: '' }); setExitForm({ lotId: '', quantity: '', reason: '' });
}} }}
title={selectedOrderId === order.id ? 'Fechar detalhes da OP' : 'Abrir detalhes da OP'} title="Abrir ficha da OP"
aria-label={`${selectedOrderId === order.id ? 'Fechar' : 'Abrir'} detalhes da OP ${order.number || order.id}`} aria-label={`Abrir ficha da OP ${order.number || order.id}`}
aria-expanded={selectedOrderId === order.id} className="inline-flex h-9 items-center justify-center gap-2 rounded-lg border border-dark-border bg-dark-input px-3 text-xs font-bold text-dark-muted transition-colors hover:border-brand-primary hover:text-brand-primary cursor-pointer"
className={`inline-flex h-9 w-9 cursor-pointer items-center justify-center rounded-lg border transition-colors hover:border-brand-primary hover:text-brand-primary ${selectedOrderId === order.id ? 'border-brand-primary bg-brand-primary/10 text-brand-primary' : 'border-dark-border bg-dark-input text-dark-muted'}`}
> >
{selectedOrderId === order.id ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />} Abrir OP
<ChevronDown className="h-4 w-4" />
</button> </button>
</td> </td>
</tr> </tr>
{selectedOrderId === order.id && <tr className="bg-dark-input/20"><td colSpan={5} className="px-6 pb-5 pt-3">{renderOrderDetails(order)}</td></tr>}
</Fragment>
); );
})} })}
</tbody> </tbody>
@@ -550,7 +730,7 @@ const ProductionOrders = () => {
</div> </div>
)} )}
<PaginationControls {totalPages > 1 && <PaginationControls
totalItems={filteredOrders.length} totalItems={filteredOrders.length}
currentPage={safeCurrentPage} currentPage={safeCurrentPage}
totalPages={totalPages} totalPages={totalPages}
@@ -565,13 +745,28 @@ const ProductionOrders = () => {
setItemsPerPage(pageSize); setItemsPerPage(pageSize);
setCurrentPage(1); setCurrentPage(1);
}} }}
/> />}
</div> </div>
</> </>
)} )}
</div> </div>
)} )}
{selectedOrder && <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-4 backdrop-blur-sm" role="dialog" aria-modal="true" aria-label={`Ficha da OP ${selectedOrder.number || selectedOrder.id}`} onClick={() => busyStatusOrderId !== selectedOrder.id && setSelectedOrderId(null)}>
<section className="flex max-h-[calc(100vh-2rem)] w-full max-w-5xl flex-col overflow-hidden rounded-2xl border border-dark-border bg-dark-card shadow-2xl" onClick={(event) => event.stopPropagation()}>
<header className="flex items-start justify-between gap-4 border-b border-dark-border px-5 py-4">
<div className="min-w-0"><div className="flex flex-wrap items-center gap-2"><span className="font-mono text-xs font-bold text-brand-primary">{selectedOrder.number || `OP #${selectedOrder.id}`}</span><span className={`inline-flex items-center gap-2 rounded-full border px-2.5 py-1 text-[10px] font-bold ${getStatusStyle(selectedOrder.status, selectedOrder.statusLabel).className}`}><span className={`h-1.5 w-1.5 rounded-full ${getStatusStyle(selectedOrder.status, selectedOrder.statusLabel).dotClass}`} />{getStatusStyle(selectedOrder.status, selectedOrder.statusLabel).label}</span></div><h2 className="mt-2 truncate text-lg font-bold text-dark-text" title={selectedOrder.productDescription}>{selectedOrder.productDescription}</h2><div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs font-semibold text-dark-muted"><span className="font-mono">{selectedOrder.productSku || 'Sem SKU'}</span>{selectedOrder.productionPlanNumber && <Link to="/production-plans" className="text-brand-primary hover:underline">{selectedOrder.productionPlanNumber}</Link>}<span>Previsão: {formatDate(selectedOrder.expectedDate)}</span></div></div>
<div className="flex shrink-0 items-center gap-2"><button type="button" onClick={() => printProductionSheet(selectedOrder)} title="Imprimir ficha" aria-label="Imprimir ficha" 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-brand-primary cursor-pointer"><Printer className="h-4 w-4" /></button><button type="button" onClick={() => setSelectedOrderId(null)} disabled={busyStatusOrderId === selectedOrder.id} title="Fechar" aria-label="Fechar ficha da OP" 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-60 cursor-pointer"><X className="h-4 w-4" /></button></div>
</header>
<div className="overflow-y-auto 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>}
{selectedOrderNextStage || selectedOrderPreviousStage ? <section className="flex flex-col gap-3 rounded-xl border border-brand-primary/25 bg-brand-primary/[0.055] p-4 sm:flex-row sm:items-center sm:justify-between"><div><p className="text-[10px] font-bold uppercase tracking-wider text-brand-primary">Fluxo de produção</p><p className="mt-1 text-sm font-bold text-dark-text">{selectedOrderNextStage?.label || selectedOrderPreviousStage?.label}</p><p className="mt-1 text-xs font-semibold text-dark-muted">{selectedOrderNextStage?.description || selectedOrderPreviousStage?.description}</p></div><div className="flex shrink-0 flex-wrap gap-2"><button type="button" onClick={() => selectedOrderPreviousStage && void handleStatusChange(selectedOrder, selectedOrderPreviousStage.status)} disabled={!selectedOrderPreviousStage || busyStatusOrderId === selectedOrder.id} className="inline-flex h-10 items-center justify-center gap-2 rounded-xl border border-dark-border bg-dark-input px-4 text-sm font-bold text-dark-text transition-colors hover:border-brand-primary disabled:cursor-not-allowed disabled:opacity-60 cursor-pointer"><Undo2 className="h-4 w-4" />{busyStatusOrderId === selectedOrder.id ? 'Atualizando...' : selectedOrderPreviousStage?.label || 'Sem etapa anterior'}</button>{selectedOrderNextStage && <button type="button" onClick={() => void handleStatusChange(selectedOrder, selectedOrderNextStage.status)} disabled={busyStatusOrderId === selectedOrder.id} className="inline-flex h-10 items-center justify-center gap-2 rounded-xl 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-60 cursor-pointer">{busyStatusOrderId === selectedOrder.id ? 'Atualizando...' : selectedOrderNextStage.label}<ChevronDown className="h-4 w-4 -rotate-90" /></button>}</div></section> : <section className="rounded-xl border border-dark-border bg-dark-input/45 px-4 py-3 text-sm font-semibold text-dark-muted">Esta OP foi cancelada e não possui próxima etapa operacional.</section>}
<div className="mt-5">{renderOrderDetails(selectedOrder)}</div>
</div>
<footer className="flex flex-col-reverse gap-2 border-t border-dark-border px-5 py-4 sm:flex-row sm:items-center sm:justify-between"><div>{selectedOrderCanReturnToCutPlan && <button type="button" onClick={() => void returnOrderToCutPlan(selectedOrder)} disabled={busyStatusOrderId === selectedOrder.id} className="inline-flex h-10 items-center gap-2 rounded-xl border border-dark-border bg-dark-input px-3 text-sm font-bold text-dark-muted transition-colors hover:border-amber-400/45 hover:text-amber-200 disabled:cursor-not-allowed disabled:opacity-60 cursor-pointer"><Undo2 className="h-4 w-4" />Devolver ao Plano de Corte</button>}</div><button type="button" onClick={() => setSelectedOrderId(null)} disabled={busyStatusOrderId === selectedOrder.id} className="h-10 rounded-xl border border-dark-border bg-dark-input px-4 text-sm font-bold text-dark-text transition-colors hover:border-brand-primary disabled:cursor-not-allowed disabled:opacity-60 cursor-pointer">Fechar</button></footer>
</section>
</div>}
</div> </div>
); );
}; };

View File

@@ -0,0 +1,290 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { Link } from 'react-router-dom';
import { ArrowLeft, CalendarDays, Check, CheckCircle2, ChevronDown, ClipboardCheck, ClipboardList, Factory, Loader2, PlayCircle, RefreshCw, UserRound, X, type LucideIcon } from 'lucide-react';
import { fetchOperationalUsers, fetchProductionPlans, saveProductionPlanReview, updateProductionPlan, updateProductionPlanStatus } from '../dataService';
import type { OperationalUser, ProductionOrderStatus, ProductionPlan, ProductionPlanReviewDecision } from '../types';
const statusOptions: Array<{ value: ProductionOrderStatus; label: string; className: string }> = [
{ value: 'open', label: 'Em aberto', className: 'border-amber-400/35 bg-amber-400/10 text-amber-300' },
{ value: 'cutting', label: 'Em corte', className: 'border-amber-300/35 bg-amber-300/10 text-amber-200' },
{ value: 'sewing', label: 'Em costura', className: 'border-sky-400/35 bg-sky-400/10 text-sky-300' },
{ value: 'review', label: 'Em revisão', className: 'border-violet-400/35 bg-violet-400/10 text-violet-300' },
{ value: 'finished', label: 'Finalizada', className: 'border-emerald-400/35 bg-emerald-400/10 text-emerald-300' },
{ value: 'canceled', label: 'Cancelada', className: 'border-zinc-500/35 bg-zinc-500/10 text-zinc-400' }
];
type ProductionPlanStatusTab = 'all' | 'open' | 'cutting' | 'sewing' | 'review' | 'finished' | 'canceled';
const statusTabs: Array<{ key: ProductionPlanStatusTab; label: string; dotClass: string }> = [
{ key: 'all', label: 'Todos', dotClass: 'bg-dark-muted' },
{ key: 'open', label: 'Em aberto', dotClass: 'bg-amber-400' },
{ key: 'cutting', label: 'Em corte', dotClass: 'bg-amber-300' },
{ key: 'sewing', label: 'Em costura', dotClass: 'bg-sky-400' },
{ key: 'review', label: 'Em revisão', dotClass: 'bg-violet-400' },
{ key: 'finished', label: 'Finalizada', dotClass: 'bg-emerald-400' },
{ key: 'canceled', label: 'Cancelada', dotClass: 'bg-zinc-500' }
];
const statusStyle = (status: string) => statusOptions.find(option => option.value === status)?.className || 'border-dark-border bg-dark-input text-dark-muted';
const formatNumber = (value: number) => new Intl.NumberFormat('pt-BR', { maximumFractionDigits: 2 }).format(value);
const formatDate = (value: string | null) => value ? new Intl.DateTimeFormat('pt-BR').format(new Date(`${value.slice(0, 10)}T00:00:00`)) : '-';
const planMetrics: Array<{ label: string; value: string | number; icon: LucideIcon; tone: string }> = [
{ label: 'Planos ativos', value: 0, icon: ClipboardList, tone: 'text-brand-primary' },
{ label: 'OPs planejadas', value: 0, icon: Factory, tone: 'text-sky-300' },
{ label: 'Unidades em fluxo', value: 0, icon: PlayCircle, tone: 'text-amber-300' }
];
const ProductionPlans = () => {
const [plans, setPlans] = useState<ProductionPlan[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [isRefreshing, setIsRefreshing] = useState(false);
const [isLoadingOperationalUsers, setIsLoadingOperationalUsers] = useState(false);
const [busyPlanId, setBusyPlanId] = useState<number | null>(null);
const [operationalUsers, setOperationalUsers] = useState<OperationalUser[]>([]);
const [responsiblePickerPlanId, setResponsiblePickerPlanId] = useState<number | null>(null);
const [reviewingPlanId, setReviewingPlanId] = useState<number | null>(null);
const [isReviewUserPickerOpen, setIsReviewUserPickerOpen] = useState(false);
const [statusFilter, setStatusFilter] = useState<ProductionPlanStatusTab>('all');
const [reviewForm, setReviewForm] = useState({
decision: 'pending' as ProductionPlanReviewDecision,
materialsReserved: false,
cutSpecificationReady: false,
sizeAndColorReviewed: false,
responsibleAndDateDefined: false,
notes: '',
reviewedBy: '',
});
const [message, setMessage] = useState('');
const loadPlans = useCallback(async () => {
setIsLoading(true);
try {
setPlans(await fetchProductionPlans());
} catch (error) {
setMessage(error instanceof Error ? error.message : 'Não foi possível carregar os planos.');
} finally {
setIsLoading(false);
}
}, []);
const loadOperationalUsers = useCallback(async () => {
setIsLoadingOperationalUsers(true);
try {
setOperationalUsers(await fetchOperationalUsers());
} finally {
setIsLoadingOperationalUsers(false);
}
}, []);
useEffect(() => {
const timer = window.setTimeout(() => { void Promise.all([loadPlans(), loadOperationalUsers()]); }, 0);
return () => window.clearTimeout(timer);
}, [loadOperationalUsers, loadPlans]);
useEffect(() => {
if (!message) return undefined;
const timer = window.setTimeout(() => setMessage(''), 5_000);
return () => window.clearTimeout(timer);
}, [message]);
const activePlans = useMemo(() => plans.filter(plan => !['finished', 'canceled'].includes(plan.status)), [plans]);
const totalOpenQuantity = useMemo(() => activePlans.reduce((total, plan) => total + plan.totalQuantity, 0), [activePlans]);
const reviewingPlan = useMemo(() => plans.find(plan => plan.id === reviewingPlanId) || null, [plans, reviewingPlanId]);
const filteredPlans = useMemo(() => (
statusFilter === 'all' ? plans : plans.filter(plan => plan.status === statusFilter)
), [plans, statusFilter]);
const refreshPlans = async () => {
setIsRefreshing(true);
try {
await Promise.all([loadPlans(), loadOperationalUsers()]);
} finally {
setIsRefreshing(false);
}
};
const toggleResponsiblePicker = (planId: number) => {
setResponsiblePickerPlanId(current => current === planId ? null : planId);
if (!operationalUsers.length) void loadOperationalUsers();
};
const toggleReviewUserPicker = () => {
setIsReviewUserPickerOpen(current => !current);
if (!operationalUsers.length) void loadOperationalUsers();
};
const movePlan = async (plan: ProductionPlan, status: ProductionOrderStatus) => {
if (status === plan.status) return;
setBusyPlanId(plan.id);
setMessage('');
try {
const updated = await updateProductionPlanStatus(plan.id, status);
setPlans(current => current.map(item => item.id === updated.id ? updated : item));
setMessage(`${updated.number} movido para ${updated.statusLabel}. As OPs ainda ativas acompanharam o plano.`);
} catch (error) {
setMessage(error instanceof Error ? error.message : 'Não foi possível mover o plano.');
} finally {
setBusyPlanId(null);
}
};
const changeResponsible = async (plan: ProductionPlan, user: OperationalUser | null) => {
setBusyPlanId(plan.id);
setMessage('');
try {
const updated = await updateProductionPlan(plan.id, {
name: plan.name,
contractor: plan.contractor,
responsibleUserId: user?.id ?? null,
responsibleName: user?.name || '',
plannedStartDate: plan.plannedStartDate,
expectedDate: plan.expectedDate,
notes: plan.notes,
source: plan.source
});
setPlans(current => current.map(item => item.id === updated.id ? updated : item));
setResponsiblePickerPlanId(null);
setMessage(updated.responsibleName ? `${updated.number} atribuído a ${updated.responsibleName}.` : `${updated.number} está sem responsável.`);
} catch (error) {
setMessage(error instanceof Error ? error.message : 'Não foi possível atualizar o responsável.');
} finally {
setBusyPlanId(null);
}
};
const changeExpectedDate = async (plan: ProductionPlan, expectedDate: string) => {
setBusyPlanId(plan.id);
setMessage('');
try {
const updated = await updateProductionPlan(plan.id, {
name: plan.name,
contractor: plan.contractor,
responsibleUserId: plan.responsibleUserId,
responsibleName: plan.responsibleName,
plannedStartDate: plan.plannedStartDate,
expectedDate: expectedDate || null,
notes: plan.notes,
source: plan.source
});
setPlans(current => current.map(item => item.id === updated.id ? updated : item));
setMessage(expectedDate ? `Prazo de ${updated.number} atualizado.` : `Prazo de ${updated.number} removido.`);
} catch (error) {
setMessage(error instanceof Error ? error.message : 'Não foi possível atualizar o prazo.');
} finally {
setBusyPlanId(null);
}
};
const openReview = (plan: ProductionPlan) => {
const checklist = plan.review?.checklist || {};
setReviewForm({
decision: plan.review?.decision || 'pending',
materialsReserved: Boolean(checklist.materialsReserved),
cutSpecificationReady: Boolean(checklist.cutSpecificationReady),
sizeAndColorReviewed: Boolean(checklist.sizeAndColorReviewed),
responsibleAndDateDefined: Boolean(checklist.responsibleAndDateDefined),
notes: plan.review?.notes || '',
reviewedBy: plan.review?.reviewedBy || plan.responsibleName || '',
});
setResponsiblePickerPlanId(null);
setIsReviewUserPickerOpen(false);
setReviewingPlanId(plan.id);
};
const submitReview = async (plan: ProductionPlan) => {
setBusyPlanId(plan.id);
setMessage('');
try {
const updated = await saveProductionPlanReview(plan.id, {
decision: reviewForm.decision,
checklist: {
materialsReserved: reviewForm.materialsReserved,
cutSpecificationReady: reviewForm.cutSpecificationReady,
sizeAndColorReviewed: reviewForm.sizeAndColorReviewed,
responsibleAndDateDefined: reviewForm.responsibleAndDateDefined,
},
notes: reviewForm.notes,
reviewedBy: reviewForm.reviewedBy,
});
setPlans(current => current.map(item => item.id === updated.id ? updated : item));
setReviewingPlanId(null);
setMessage(updated.review?.decision === 'approved' ? `${updated.number} liberado para operação.` : `Revisão operacional de ${updated.number} registrada.`);
} catch (error) {
setMessage(error instanceof Error ? error.message : 'Não foi possível salvar a revisão.');
} finally {
setBusyPlanId(null);
}
};
const reviewDecisionLabel: Record<ProductionPlanReviewDecision, string> = { pending: 'Em revisão', approved: 'Liberado', adjustments_required: 'Ajustes necessários' };
const reviewDecisionStyle = (decision?: ProductionPlanReviewDecision) => decision === 'approved'
? 'border-emerald-400/30 bg-emerald-400/10 text-emerald-300'
: decision === 'adjustments_required'
? 'border-amber-400/30 bg-amber-400/10 text-amber-300'
: 'border-dark-border bg-dark-input text-dark-muted';
return (
<div className="space-y-5">
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
<div>
<Link to="/supplies" className="mb-3 inline-flex items-center gap-2 text-sm font-bold text-zinc-500 transition-colors hover:text-zinc-900 dark:text-dark-muted dark:hover:text-dark-text"><ArrowLeft className="h-4 w-4" />Suprimentos</Link>
<h1 className="text-2xl font-bold text-zinc-900 dark:text-dark-text">Planos de Produção</h1>
<p className="mt-2 max-w-3xl font-medium text-zinc-500 dark:text-dark-muted">Acompanhe cada frente de produção, o responsável e o estágio das OPs sem perder o histórico do plano.</p>
</div>
<button type="button" onClick={() => void refreshPlans()} disabled={isRefreshing} title="Atualizar planos" aria-label="Atualizar planos" className="inline-flex h-10 w-10 items-center justify-center rounded-xl border border-dark-border bg-dark-card text-dark-muted transition-colors hover:border-brand-primary hover:text-brand-primary disabled:cursor-not-allowed disabled:opacity-60 cursor-pointer"><RefreshCw className={`h-4 w-4 ${isRefreshing ? 'animate-spin' : ''}`} /></button>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
{planMetrics.map(metric => {
const value = metric.label === 'Planos ativos'
? activePlans.length
: metric.label === 'OPs planejadas'
? activePlans.reduce((total, plan) => total + plan.orderCount, 0)
: formatNumber(totalOpenQuantity);
const MetricIcon = metric.icon;
const description = metric.label === 'Planos ativos' ? 'Frentes em acompanhamento' : metric.label === 'OPs planejadas' ? 'Ordens vinculadas aos planos ativos' : 'Quantidade ainda em operação';
return <div key={metric.label} className="rounded-2xl border border-dark-border bg-dark-card p-5 shadow-sm"><div className="flex items-center justify-between"><p className="text-sm font-bold text-dark-muted">{metric.label}</p><span className={`rounded-lg border border-dark-border bg-dark-input p-2 ${metric.tone}`}><MetricIcon className="h-4 w-4" /></span></div><p className="mt-3 text-3xl font-bold text-dark-text">{value}</p><p className="mt-1 text-xs font-semibold text-dark-muted">{description}</p></div>;
})}
</div>
{message && <div className="rounded-xl border border-dark-border bg-dark-card px-4 py-3 text-sm font-bold text-dark-muted">{message}</div>}
<div className="rounded-2xl border border-dark-border bg-dark-card shadow-sm">
<div className="border-b border-dark-border px-5 py-4"><div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between"><div><h2 className="font-bold text-dark-text">Fila de planos</h2><p className="mt-1 text-xs font-semibold text-dark-muted">Mover um plano atualiza as OPs locais ativas. OPs finalizadas ou canceladas preservam o histórico.</p></div><span className="w-fit rounded-full border border-dark-border bg-dark-input px-2.5 py-1 text-xs font-bold text-dark-muted">{filteredPlans.length} {filteredPlans.length === 1 ? 'plano' : 'planos'}</span></div><div className="mt-4 flex gap-1 overflow-x-auto" role="tablist" aria-label="Status dos planos">{statusTabs.map(tab => { const count = tab.key === 'all' ? plans.length : plans.filter(plan => plan.status === tab.key).length; const isSelected = statusFilter === tab.key; return <button key={tab.key} type="button" role="tab" aria-selected={isSelected} onClick={() => setStatusFilter(tab.key)} className={`shrink-0 border-b-2 px-3 py-2 text-left text-xs font-bold transition-colors cursor-pointer ${isSelected ? 'border-brand-primary text-dark-text' : 'border-transparent text-dark-muted hover:text-dark-text'}`}><span className="flex items-center gap-2"><span className={`h-2 w-2 rounded-full ${tab.dotClass}`} />{tab.label}</span><span className="mt-1 block text-sm text-dark-text">{count}</span></button>; })}</div></div>
{isLoading ? <div className="flex min-h-56 items-center justify-center text-brand-primary"><Loader2 className="h-7 w-7 animate-spin" /></div> : filteredPlans.length ? (
<div className="divide-y divide-dark-border">
{filteredPlans.map(plan => (
<div key={plan.id} className="p-5">
<div className="min-w-0"><div className="flex flex-wrap items-center gap-2"><span className="font-mono text-xs font-bold text-brand-primary">{plan.number}</span><span className={`rounded-full border px-2.5 py-1 text-xs font-bold ${statusStyle(plan.status)}`}>{plan.statusLabel}</span>{plan.review && <span className={`rounded-full border px-2 py-1 text-[10px] font-bold ${reviewDecisionStyle(plan.review.decision)}`}>{reviewDecisionLabel[plan.review.decision]}</span>}</div><p className="mt-2 text-base font-bold text-dark-text">{plan.name}</p>{plan.notes && <p className="mt-1 max-w-3xl text-xs font-semibold leading-5 text-dark-muted">{plan.notes}</p>}</div>
<div className="mt-4 grid gap-3 border-t border-dark-border pt-4 sm:grid-cols-2 xl:grid-cols-4">
<div className="rounded-xl border border-dark-border bg-dark-input/45 p-3"><p className="text-[10px] font-bold uppercase tracking-wider text-dark-muted">Escopo</p><p className="mt-1 text-sm font-bold text-dark-text">{plan.orderCount} {plan.orderCount === 1 ? 'OP' : 'OPs'} · {formatNumber(plan.totalQuantity)} un.</p><p className="mt-1 text-xs font-semibold text-dark-muted">Corte previsto pelo plano</p></div>
<div className="rounded-xl border border-dark-border bg-dark-input/45 p-3"><p className="text-[10px] font-bold uppercase tracking-wider text-dark-muted">Andamento</p><p className="mt-1 text-sm font-bold text-dark-text">Cortado {formatNumber(plan.actualCutQuantity)} · final {formatNumber(plan.finishedQuantity)}</p><div className="mt-2 flex flex-wrap gap-1.5">{statusOptions.filter(option => Number(plan.statusCounts[option.value] || 0) > 0).map(option => <span key={option.value} className={`rounded-full border px-2 py-0.5 text-[10px] font-bold ${statusStyle(option.value)}`}>{plan.statusCounts[option.value]} {option.label.toLowerCase()}</span>)}</div></div>
<div className="rounded-xl border border-dark-border bg-dark-input/45 p-3"><div className="flex items-center gap-1.5 text-[10px] font-bold uppercase tracking-wider text-dark-muted"><CalendarDays className="h-3.5 w-3.5 shrink-0" /><span>Prazo de conclusão</span></div><input type="date" defaultValue={plan.expectedDate || ''} disabled={busyPlanId === plan.id} onChange={(event) => void changeExpectedDate(plan, event.target.value)} className="mt-3 h-10 w-full rounded-lg border border-dark-border bg-dark-card px-3 text-sm font-bold text-dark-text outline-none transition-colors focus:border-brand-primary disabled:cursor-not-allowed disabled:opacity-60 cursor-pointer" aria-label={`Prazo de conclusão do plano ${plan.number}`} /><div className="mt-2 flex items-center justify-between gap-2 text-xs font-semibold text-dark-muted"><span>Início</span><span>{formatDate(plan.plannedStartDate)}</span></div></div>
<div className="relative rounded-xl border border-dark-border bg-dark-input/45 p-3"><p className="text-[10px] font-bold uppercase tracking-wider text-dark-muted">Responsável</p><button type="button" disabled={busyPlanId === plan.id} onClick={() => toggleResponsiblePicker(plan.id)} aria-expanded={responsiblePickerPlanId === plan.id} className="mt-3 flex h-10 w-full items-center justify-between gap-2 rounded-lg border border-dark-border bg-dark-card px-3 text-left text-sm font-semibold text-dark-text transition-colors hover:border-brand-primary disabled:cursor-not-allowed disabled:opacity-60 cursor-pointer"><span className="flex min-w-0 items-center gap-2"><UserRound className="h-4 w-4 shrink-0 text-brand-primary" /><span className="truncate">{plan.responsibleName || 'Selecionar responsável'}</span></span><ChevronDown className={`h-4 w-4 shrink-0 text-dark-muted transition-transform ${responsiblePickerPlanId === plan.id ? 'rotate-180' : ''}`} /></button>{responsiblePickerPlanId === plan.id && <div className="absolute inset-x-3 top-[calc(100%-0.75rem)] z-30 mt-2 overflow-hidden rounded-xl border border-dark-border bg-dark-card shadow-2xl"><div className="border-b border-dark-border px-3 py-2 text-[10px] font-bold uppercase tracking-wider text-dark-muted">Usuários ativos do Graphs</div><div className="max-h-56 overflow-y-auto p-1.5">{isLoadingOperationalUsers ? <p className="flex items-center gap-2 px-2 py-3 text-xs font-semibold text-dark-muted"><Loader2 className="h-4 w-4 animate-spin text-brand-primary" />Carregando usuários...</p> : operationalUsers.length ? operationalUsers.map(user => <button key={user.id ?? 'super-admin'} type="button" disabled={busyPlanId === plan.id} onClick={() => void changeResponsible(plan, user)} className="flex w-full items-center justify-between gap-3 rounded-lg px-2.5 py-2 text-left text-sm font-semibold text-dark-text transition-colors hover:bg-dark-input disabled:opacity-60 cursor-pointer"><span className="truncate">{user.name}</span>{plan.responsibleUserId === user.id && plan.responsibleName === user.name && <Check className="h-4 w-4 shrink-0 text-brand-primary" />}</button>) : <p className="px-2 py-3 text-xs font-semibold text-dark-muted">Nenhum usuário ativo encontrado.</p>}</div>{plan.responsibleName && <button type="button" disabled={busyPlanId === plan.id} onClick={() => void changeResponsible(plan, null)} className="w-full border-t border-dark-border px-3 py-2 text-left text-xs font-bold text-dark-muted transition-colors hover:bg-dark-input hover:text-red-300 disabled:opacity-60 cursor-pointer">Remover responsável</button>}</div>}</div>
</div>
<div className="mt-3 flex flex-col gap-3 rounded-xl border border-dark-border bg-dark-input/30 p-3 sm:flex-row sm:items-end sm:justify-between"><div><p className="text-xs font-bold text-dark-text">Ações do plano</p><p className="mt-0.5 text-xs font-semibold text-dark-muted">Revise a liberação antes de avançar a operação.</p></div><div className="flex flex-col gap-2 sm:flex-row sm:items-end"><button type="button" disabled={busyPlanId === plan.id} onClick={() => openReview(plan)} className="inline-flex h-10 items-center justify-center gap-2 rounded-lg border border-dark-border bg-dark-card px-3 text-xs font-bold text-dark-text transition-colors hover:border-brand-primary disabled:cursor-not-allowed disabled:opacity-60 cursor-pointer"><ClipboardCheck className="h-4 w-4 text-brand-primary" />Checklist</button><label className="min-w-[156px] text-[10px] font-bold uppercase tracking-wider text-dark-muted">Mover plano<select value={plan.status} disabled={busyPlanId === plan.id} onChange={(event) => void movePlan(plan, event.target.value as ProductionOrderStatus)} className="mt-1 h-10 w-full rounded-lg border border-dark-border bg-dark-card px-3 text-sm font-bold normal-case tracking-normal text-dark-text outline-none focus:border-brand-primary disabled:opacity-60 cursor-pointer"><option value={plan.status}>{busyPlanId === plan.id ? 'Atualizando...' : plan.statusLabel}</option>{statusOptions.filter(option => option.value !== plan.status).map(option => <option key={option.value} value={option.value}>{option.label}</option>)}</select></label></div></div>
</div>
))}
</div>
) : <div className="flex min-h-56 flex-col items-center justify-center p-8 text-center"><CheckCircle2 className="h-8 w-8 text-brand-primary" /><h3 className="mt-3 font-bold text-dark-text">{plans.length ? 'Nenhum plano neste status' : 'Nenhum plano confirmado'}</h3><p className="mt-1 text-sm font-semibold text-dark-muted">{plans.length ? 'Escolha outro status para visualizar os demais planos.' : 'Gere OPs a partir do Plano de Corte para criar o primeiro plano PCP.'}</p></div>}
</div>
{reviewingPlan && <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-4 backdrop-blur-sm" role="dialog" aria-modal="true" aria-label={`Checklist do plano ${reviewingPlan.number}`} onClick={() => busyPlanId !== reviewingPlan.id && setReviewingPlanId(null)}>
<section className="flex max-h-[calc(100vh-2rem)] w-full max-w-3xl flex-col overflow-hidden rounded-2xl border border-dark-border bg-dark-card shadow-2xl" onClick={(event) => event.stopPropagation()}>
<header className="flex items-start justify-between gap-4 border-b border-dark-border px-5 py-4">
<div className="min-w-0"><div className="flex flex-wrap items-center gap-2"><span className="font-mono text-xs font-bold text-brand-primary">{reviewingPlan.number}</span><span className={`rounded-full border px-2 py-1 text-[10px] font-bold ${statusStyle(reviewingPlan.status)}`}>{reviewingPlan.statusLabel}</span></div><h2 className="mt-2 text-lg font-bold text-dark-text">Checklist de liberação</h2><p className="mt-1 truncate text-sm font-semibold text-dark-muted" title={reviewingPlan.name}>{reviewingPlan.name}</p></div>
<button type="button" onClick={() => setReviewingPlanId(null)} disabled={busyPlanId === reviewingPlan.id} title="Fechar" aria-label="Fechar checklist" 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 disabled:cursor-not-allowed disabled:opacity-60 cursor-pointer"><X className="h-4 w-4" /></button>
</header>
<div className="overflow-y-auto p-5">
<p className="text-sm font-medium leading-6 text-dark-muted">Revise os pontos de liberação antes de avançar a operação. Este registro não movimenta estoque nem substitui o recebimento físico.</p>
<section className="mt-5"><h3 className="text-sm font-bold text-dark-text">Conferências</h3><div className="mt-3 grid gap-2 sm:grid-cols-2">{[{ key: 'materialsReserved', label: 'Materiais reservados' }, { key: 'cutSpecificationReady', label: 'Ficha e corte definidos' }, { key: 'sizeAndColorReviewed', label: 'Tamanhos e cores revisados' }, { key: 'responsibleAndDateDefined', label: 'Responsável e prazo definidos' }].map(item => <label key={item.key} className="flex min-h-12 items-center gap-3 rounded-xl border border-dark-border bg-dark-input/45 px-3 py-2.5 text-sm font-bold text-dark-text transition-colors hover:border-brand-primary/50 cursor-pointer"><input type="checkbox" checked={reviewForm[item.key as keyof Pick<typeof reviewForm, 'materialsReserved' | 'cutSpecificationReady' | 'sizeAndColorReviewed' | 'responsibleAndDateDefined'>]} onChange={(event) => setReviewForm(current => ({ ...current, [item.key]: event.target.checked }))} className="h-4 w-4 shrink-0 accent-brand-primary cursor-pointer" />{item.label}</label>)}</div></section>
<section className="mt-5 grid gap-4 sm:grid-cols-2"><label className="text-xs font-bold text-dark-muted">Decisão<select value={reviewForm.decision} onChange={(event) => setReviewForm(current => ({ ...current, decision: event.target.value as ProductionPlanReviewDecision }))} className="mt-1.5 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="pending">Em revisão</option><option value="approved">Liberar para operação</option><option value="adjustments_required">Solicitar ajustes</option></select></label><div className="relative text-xs font-bold text-dark-muted"><p>Revisado por</p><button type="button" onClick={toggleReviewUserPicker} aria-expanded={isReviewUserPickerOpen} className="mt-1.5 flex h-10 w-full items-center justify-between gap-2 rounded-lg border border-dark-border bg-dark-input px-3 text-left text-sm font-semibold text-dark-text outline-none transition-colors hover:border-brand-primary cursor-pointer"><span className="flex min-w-0 items-center gap-2"><UserRound className="h-4 w-4 shrink-0 text-brand-primary" /><span className="truncate">{reviewForm.reviewedBy || 'Selecionar usuário'}</span></span><ChevronDown className={`h-4 w-4 shrink-0 text-dark-muted transition-transform ${isReviewUserPickerOpen ? 'rotate-180' : ''}`} /></button>{isReviewUserPickerOpen && <div className="absolute inset-x-0 top-full z-20 mt-2 overflow-hidden rounded-xl border border-dark-border bg-dark-card shadow-2xl"><div className="border-b border-dark-border px-3 py-2 text-[10px] font-bold uppercase tracking-wider text-dark-muted">Usuários ativos do Graphs</div><div className="max-h-52 overflow-y-auto p-1.5">{isLoadingOperationalUsers ? <p className="flex items-center gap-2 px-2 py-3 text-xs font-semibold text-dark-muted"><Loader2 className="h-4 w-4 animate-spin text-brand-primary" />Carregando usuários...</p> : operationalUsers.length ? operationalUsers.map(user => <button key={user.id ?? 'super-admin'} type="button" onClick={() => { setReviewForm(current => ({ ...current, reviewedBy: user.name })); setIsReviewUserPickerOpen(false); }} className="flex w-full items-center justify-between gap-3 rounded-lg px-2.5 py-2 text-left text-sm font-semibold text-dark-text transition-colors hover:bg-dark-input cursor-pointer"><span className="truncate">{user.name}</span>{reviewForm.reviewedBy === user.name && <Check className="h-4 w-4 shrink-0 text-brand-primary" />}</button>) : <p className="px-2 py-3 text-xs font-semibold text-dark-muted">Nenhum usuário ativo encontrado.</p>}</div>{reviewForm.reviewedBy && <button type="button" onClick={() => { setReviewForm(current => ({ ...current, reviewedBy: '' })); setIsReviewUserPickerOpen(false); }} className="w-full border-t border-dark-border px-3 py-2 text-left text-xs font-bold text-dark-muted transition-colors hover:bg-dark-input hover:text-red-300 cursor-pointer">Remover revisor</button>}</div>}</div><label className="sm:col-span-2 text-xs font-bold text-dark-muted">Observação<input value={reviewForm.notes} onChange={(event) => setReviewForm(current => ({ ...current, notes: event.target.value }))} className="mt-1.5 h-10 w-full rounded-lg border border-dark-border bg-dark-input px-3 text-sm font-semibold text-dark-text outline-none transition-colors focus:border-brand-primary" placeholder="Ajuste, orientação ou motivo" /></label></section>
</div>
<footer className="flex flex-col-reverse gap-2 border-t border-dark-border px-5 py-4 sm:flex-row sm:items-center sm:justify-end"><button type="button" onClick={() => setReviewingPlanId(null)} disabled={busyPlanId === reviewingPlan.id} className="h-10 rounded-xl border border-dark-border bg-dark-input px-4 text-sm font-bold text-dark-text transition-colors hover:border-brand-primary disabled:cursor-not-allowed disabled:opacity-60 cursor-pointer">Cancelar</button><button type="button" disabled={busyPlanId === reviewingPlan.id} onClick={() => void submitReview(reviewingPlan)} className="inline-flex h-10 items-center justify-center gap-2 rounded-xl 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-60 cursor-pointer"><Check className="h-4 w-4" />{busyPlanId === reviewingPlan.id ? 'Salvando...' : 'Salvar checklist'}</button></footer>
</section>
</div>}
</div>
);
};
export default ProductionPlans;

View File

@@ -0,0 +1,161 @@
import { type FormEvent, useCallback, useEffect, useMemo, useState } from 'react';
import { Link, useOutletContext } from 'react-router-dom';
import { AlertTriangle, ArrowLeft, ClipboardCheck, Factory, Loader2, Pencil, Plus, Save, Settings2, X } from 'lucide-react';
import DateRangePicker from '../components/DateRangePicker';
import { createProductionWorkstation, fetchProductionOrders, fetchProductionWorkstations, updateProductionWorkstation } from '../dataService';
import type { DateRange, ProductionOrderSummary, ProductionWorkstation } from '../types';
const emptySummary: ProductionOrderSummary = {
orders: [],
counts: { all: 0, open: 0, cutting: 0, sewing: 0, review: 0, finished: 0, canceled: 0 }
};
const inputClassName = 'mt-1 h-10 w-full rounded-lg border border-dark-border bg-dark-input px-3 text-sm font-semibold text-dark-text outline-none placeholder:text-dark-muted focus:border-brand-primary';
const formatQuantity = (value: number) => new Intl.NumberFormat('pt-BR', { maximumFractionDigits: 2 }).format(value);
const formatDateTime = (value: string | null) => value ? new Intl.DateTimeFormat('pt-BR', { dateStyle: 'short', timeStyle: 'short' }).format(new Date(value)) : '—';
const ProductionQuality = () => {
const { dateRange, setDateRange, refreshInterval, setRefreshInterval } = useOutletContext<{
dateRange: DateRange;
setDateRange: (range: DateRange) => void;
refreshInterval: number;
setRefreshInterval: (interval: number) => void;
}>();
const [summary, setSummary] = useState<ProductionOrderSummary>(emptySummary);
const [workstations, setWorkstations] = useState<ProductionWorkstation[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const [message, setMessage] = useState('');
const [editingStation, setEditingStation] = useState<ProductionWorkstation | null>(null);
const [stationForm, setStationForm] = useState({ name: '', code: '', color: '#38bdf8', sortOrder: '50', isActive: true });
const load = useCallback(async () => {
setIsLoading(true);
try {
const [nextSummary, nextWorkstations] = await Promise.all([
fetchProductionOrders(dateRange, undefined, { force: true }),
fetchProductionWorkstations(true)
]);
setSummary(nextSummary);
setWorkstations(nextWorkstations);
} catch (error) {
setMessage(error instanceof Error ? error.message : 'Não foi possível carregar a qualidade da produção.');
} finally {
setIsLoading(false);
}
}, [dateRange]);
useEffect(() => {
const timer = window.setTimeout(() => void load(), 0);
return () => window.clearTimeout(timer);
}, [load]);
useEffect(() => {
if (!refreshInterval) return undefined;
const interval = window.setInterval(() => void load(), refreshInterval);
return () => window.clearInterval(interval);
}, [load, refreshInterval]);
const defects = useMemo(() => summary.orders.flatMap(order => order.defects.map(defect => ({
...defect,
orderId: order.id,
orderNumber: order.number || `OP #${order.id}`,
productDescription: order.productDescription,
unit: order.unit
}))), [summary.orders]);
const totalDefectiveQuantity = useMemo(() => defects.reduce((total, defect) => total + defect.quantity, 0), [defects]);
const affectedOrders = useMemo(() => new Set(defects.map(defect => defect.orderId)).size, [defects]);
const fabricHoleDefects = useMemo(() => defects.filter(defect => /\b(?:furo|furos|furado|perfura[çc][ãa]o)\b/i.test(defect.defectType)), [defects]);
const fabricHoleQuantity = useMemo(() => fabricHoleDefects.reduce((total, defect) => total + defect.quantity, 0), [fabricHoleDefects]);
const fabricHoleLots = useMemo(() => new Set(fabricHoleDefects.map(defect => defect.lotCode).filter(Boolean)).size, [fabricHoleDefects]);
const workstationSummary = useMemo(() => {
const grouped = new Map<string, { label: string; quantity: number; occurrences: number }>();
defects.forEach(defect => {
const label = defect.workstationName || 'Sem posto';
const current = grouped.get(label) || { label, quantity: 0, occurrences: 0 };
current.quantity += defect.quantity;
current.occurrences += 1;
grouped.set(label, current);
});
return [...grouped.values()].sort((a, b) => b.quantity - a.quantity);
}, [defects]);
const defectTypeSummary = useMemo(() => {
const grouped = new Map<string, { label: string; quantity: number; occurrences: number }>();
defects.forEach(defect => {
const label = defect.defectType || 'Sem tipo';
const current = grouped.get(label) || { label, quantity: 0, occurrences: 0 };
current.quantity += defect.quantity;
current.occurrences += 1;
grouped.set(label, current);
});
return [...grouped.values()].sort((a, b) => b.quantity - a.quantity);
}, [defects]);
const recentDefects = useMemo(() => [...defects].sort((a, b) => new Date(b.occurredAt || 0).getTime() - new Date(a.occurredAt || 0).getTime()).slice(0, 12), [defects]);
const resetStationForm = () => {
setEditingStation(null);
setStationForm({ name: '', code: '', color: '#38bdf8', sortOrder: String((workstations.at(-1)?.sortOrder || 0) + 10), isActive: true });
};
const editStation = (station: ProductionWorkstation) => {
setEditingStation(station);
setStationForm({ name: station.name, code: station.code, color: station.color, sortOrder: String(station.sortOrder), isActive: station.isActive });
};
const saveStation = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
setIsSaving(true);
setMessage('');
try {
const payload = { ...stationForm, sortOrder: Number(stationForm.sortOrder || 0) };
if (editingStation) await updateProductionWorkstation(editingStation.id, payload);
else await createProductionWorkstation(payload);
await load();
setMessage(editingStation ? 'Posto de trabalho atualizado.' : 'Posto de trabalho criado.');
resetStationForm();
} catch (error) {
setMessage(error instanceof Error ? error.message : 'Não foi possível salvar o posto de trabalho.');
} finally {
setIsSaving(false);
}
};
return (
<div className="space-y-6">
<div className="flex flex-col gap-4 xl:flex-row xl:items-end xl:justify-between">
<div>
<Link to="/production-orders" className="mb-3 inline-flex items-center gap-2 text-sm font-bold text-dark-muted transition-colors hover:text-dark-text"><ArrowLeft className="h-4 w-4" />Ordens de Produção</Link>
<h1 className="text-2xl font-bold text-dark-text">Qualidade e postos</h1>
<p className="mt-2 font-medium text-dark-muted">Acompanhe defeitos por origem e mantenha as etapas reais da operação.</p>
</div>
<DateRangePicker dateRange={dateRange} onChange={setDateRange} refreshInterval={refreshInterval} setRefreshInterval={setRefreshInterval} onManualRefresh={() => void load()} />
</div>
{message && <div className="rounded-xl border border-dark-border bg-dark-card px-4 py-3 text-sm font-bold text-dark-muted">{message}</div>}
{isLoading ? <div className="flex min-h-72 items-center justify-center rounded-2xl border border-dark-border bg-dark-card text-brand-primary"><Loader2 className="h-8 w-8 animate-spin" /></div> : <>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-4">
<div className="rounded-2xl border border-dark-border bg-dark-card p-5 shadow-sm"><div className="flex items-center justify-between"><p className="text-xs font-bold uppercase tracking-widest text-dark-muted">Defeitos registrados</p><AlertTriangle className="h-5 w-5 text-amber-300" /></div><p className="mt-3 text-3xl font-bold text-dark-text">{defects.length}</p><p className="mt-1 text-xs font-semibold text-dark-muted">Ocorrências no período</p></div>
<div className="rounded-2xl border border-dark-border bg-dark-card p-5 shadow-sm"><div className="flex items-center justify-between"><p className="text-xs font-bold uppercase tracking-widest text-dark-muted">Peças afetadas</p><ClipboardCheck className="h-5 w-5 text-amber-300" /></div><p className="mt-3 text-3xl font-bold text-amber-200">{formatQuantity(totalDefectiveQuantity)}</p><p className="mt-1 text-xs font-semibold text-dark-muted">Somadas pelos apontamentos</p></div>
<div className="rounded-2xl border border-dark-border bg-dark-card p-5 shadow-sm"><div className="flex items-center justify-between"><p className="text-xs font-bold uppercase tracking-widest text-dark-muted">OPs com ocorrência</p><Factory className="h-5 w-5 text-violet-300" /></div><p className="mt-3 text-3xl font-bold text-dark-text">{affectedOrders}</p><p className="mt-1 text-xs font-semibold text-dark-muted">De {summary.orders.length} OPs no período</p></div>
<div className="rounded-2xl border border-amber-400/25 bg-amber-400/[0.045] p-5 shadow-sm"><div className="flex items-center justify-between"><p className="text-xs font-bold uppercase tracking-widest text-amber-200">Furos na malha</p><AlertTriangle className="h-5 w-5 text-amber-300" /></div><p className="mt-3 text-3xl font-bold text-amber-200">{formatQuantity(fabricHoleQuantity)}</p><p className="mt-1 text-xs font-semibold text-dark-muted">{fabricHoleDefects.length} apontamentos · {fabricHoleLots} {fabricHoleLots === 1 ? 'lote' : 'lotes'}</p></div>
</div>
<div className="grid grid-cols-1 gap-6 xl:grid-cols-2">
<section className="overflow-hidden rounded-2xl border border-dark-border bg-dark-card shadow-sm"><div className="border-b border-dark-border p-5"><h2 className="font-bold text-dark-text">Levantamento de defeitos</h2><p className="mt-1 text-xs font-semibold text-dark-muted">Priorize o maior impacto antes de atacar ocorrências isoladas.</p></div><div className="divide-y divide-dark-border">{workstationSummary.length ? workstationSummary.map(item => <div key={item.label} className="flex items-center justify-between gap-4 p-4"><div><p className="text-sm font-bold text-dark-text">{item.label}</p><p className="mt-1 text-xs font-semibold text-dark-muted">{item.occurrences} {item.occurrences === 1 ? 'ocorrência' : 'ocorrências'}</p></div><p className="text-lg font-bold text-amber-200">{formatQuantity(item.quantity)}</p></div>) : <p className="p-5 text-sm font-semibold text-dark-muted">Nenhum defeito registrado no período.</p>}</div></section>
<section className="overflow-hidden rounded-2xl border border-dark-border bg-dark-card shadow-sm"><div className="border-b border-dark-border p-5"><h2 className="font-bold text-dark-text">Tipos mais recorrentes</h2><p className="mt-1 text-xs font-semibold text-dark-muted">Use estes dados para orientar revisão, fornecedor e correção de processo.</p></div><div className="divide-y divide-dark-border">{defectTypeSummary.length ? defectTypeSummary.map(item => <div key={item.label} className="flex items-center justify-between gap-4 p-4"><div><p className="text-sm font-bold text-dark-text">{item.label}</p><p className="mt-1 text-xs font-semibold text-dark-muted">{item.occurrences} {item.occurrences === 1 ? 'ocorrência' : 'ocorrências'}</p></div><p className="text-lg font-bold text-amber-200">{formatQuantity(item.quantity)}</p></div>) : <p className="p-5 text-sm font-semibold text-dark-muted">Nenhum tipo de defeito registrado no período.</p>}</div></section>
</div>
<section className="overflow-hidden rounded-2xl border border-dark-border bg-dark-card shadow-sm"><div className="flex flex-col gap-2 border-b border-dark-border p-5 sm:flex-row sm:items-start sm:justify-between"><div><h2 className="font-bold text-dark-text">Furos na malha</h2><p className="mt-1 text-xs font-semibold text-dark-muted">Apontamentos cujo tipo de defeito contém “furo”, “furado” ou “perfuração”. Registre sempre o lote para identificar a origem.</p></div><span className="w-fit rounded-full border border-amber-400/30 bg-amber-400/10 px-2.5 py-1 text-xs font-bold text-amber-300">{formatQuantity(fabricHoleQuantity)} peças</span></div><div className="divide-y divide-dark-border">{fabricHoleDefects.length ? fabricHoleDefects.slice(0, 10).map(defect => <div key={defect.id} className="grid gap-3 p-4 sm:grid-cols-[minmax(0,1fr)_auto] sm:items-center"><div><div className="flex flex-wrap items-center gap-2"><span className="font-mono text-xs font-bold text-brand-primary">{defect.orderNumber}</span>{defect.lotCode && <span className="rounded-full border border-dark-border bg-dark-input px-2 py-0.5 text-[10px] font-bold text-dark-muted">Lote {defect.lotCode}</span>}</div><p className="mt-2 text-sm font-bold text-dark-text">{defect.productDescription}</p><p className="mt-1 text-xs font-semibold text-dark-muted">{defect.workstationName || 'Sem posto'} · {defect.origin || 'origem não informada'}</p></div><p className="text-sm font-bold text-amber-200">{formatQuantity(defect.quantity)} {defect.unit}</p></div>) : <p className="p-5 text-sm font-semibold text-dark-muted">Nenhum furo de malha registrado no período. Ao apontar o defeito na OP, use “Furo na malha” e informe o lote.</p>}</div></section>
<div className="grid grid-cols-1 gap-6 xl:grid-cols-[minmax(0,1.2fr)_minmax(380px,.8fr)]">
<section className="overflow-hidden rounded-2xl border border-dark-border bg-dark-card shadow-sm"><div className="border-b border-dark-border p-5"><h2 className="font-bold text-dark-text">Ocorrências recentes</h2><p className="mt-1 text-xs font-semibold text-dark-muted">Rastreabilidade por OP, produto, posto e lote.</p></div><div className="divide-y divide-dark-border">{recentDefects.length ? recentDefects.map(defect => <div key={defect.id} className="grid gap-3 p-4 sm:grid-cols-[minmax(0,1fr)_auto] sm:items-start"><div><div className="flex flex-wrap items-center gap-2"><span className="font-mono text-xs font-bold text-brand-primary">{defect.orderNumber}</span><span className="rounded-full border border-dark-border bg-dark-input px-2 py-0.5 text-[10px] font-bold text-dark-muted">{defect.workstationName || 'Sem posto'}</span></div><p className="mt-2 text-sm font-bold text-dark-text">{defect.defectType}</p><p className="mt-1 text-xs font-semibold text-dark-muted">{defect.productDescription} · {defect.origin || 'origem não informada'}{defect.lotCode ? ` · lote ${defect.lotCode}` : ''}</p>{defect.notes && <p className="mt-1 text-xs font-medium text-dark-muted">{defect.notes}</p>}</div><div className="text-right"><p className="text-sm font-bold text-amber-200">{formatQuantity(defect.quantity)} {defect.unit}</p><p className="mt-1 text-[11px] font-semibold text-dark-muted">{formatDateTime(defect.occurredAt)}</p></div></div>) : <p className="p-5 text-sm font-semibold text-dark-muted">Nenhuma ocorrência registrada no período.</p>}</div></section>
<section className="rounded-2xl border border-dark-border bg-dark-card p-5 shadow-sm"><div className="flex items-center justify-between gap-3"><div><h2 className="font-bold text-dark-text">Postos de trabalho</h2><p className="mt-1 text-xs font-semibold text-dark-muted">Etapas disponíveis para o apontamento da operação.</p></div><Settings2 className="h-5 w-5 text-brand-primary" /></div><form onSubmit={saveStation} className="mt-5 rounded-xl border border-dark-border bg-dark-input/40 p-3"><div className="flex items-center justify-between gap-3"><p className="text-xs font-bold text-dark-text">{editingStation ? 'Editar posto' : 'Novo posto'}</p>{editingStation && <button type="button" onClick={resetStationForm} className="inline-flex h-7 items-center gap-1 text-xs font-bold text-dark-muted hover:text-dark-text cursor-pointer"><X className="h-3.5 w-3.5" />Cancelar</button>}</div><div className="mt-3 grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-1"><label className="text-xs font-bold text-dark-muted">Nome<input required value={stationForm.name} onChange={(event) => setStationForm(current => ({ ...current, name: event.target.value }))} className={inputClassName} placeholder="Ex.: Bordado" /></label><label className="text-xs font-bold text-dark-muted">Código (opcional)<input value={stationForm.code} onChange={(event) => setStationForm(current => ({ ...current, code: event.target.value }))} className={inputClassName} placeholder="bordado" /></label><div className="grid grid-cols-2 gap-3"><label className="text-xs font-bold text-dark-muted">Ordem<input inputMode="numeric" value={stationForm.sortOrder} onChange={(event) => setStationForm(current => ({ ...current, sortOrder: event.target.value }))} className={inputClassName} /></label><label className="text-xs font-bold text-dark-muted">Cor<input type="color" value={stationForm.color} onChange={(event) => setStationForm(current => ({ ...current, color: event.target.value }))} className="mt-1 h-10 w-full cursor-pointer rounded-lg border border-dark-border bg-dark-input p-1" /></label></div><label className="flex items-center gap-2 text-xs font-bold text-dark-muted"><input type="checkbox" checked={stationForm.isActive} onChange={(event) => setStationForm(current => ({ ...current, isActive: event.target.checked }))} className="h-4 w-4 accent-brand-primary" />Disponível para novos apontamentos</label></div><button type="submit" disabled={isSaving} className="mt-4 inline-flex h-10 w-full items-center justify-center gap-2 rounded-lg border border-brand-primary/35 bg-brand-primary/10 px-3 text-sm font-bold text-brand-primary transition-colors hover:bg-brand-primary/20 disabled:opacity-50 cursor-pointer">{editingStation ? <Save className="h-4 w-4" /> : <Plus className="h-4 w-4" />}{isSaving ? 'Salvando...' : editingStation ? 'Salvar posto' : 'Adicionar posto'}</button></form><div className="mt-4 divide-y divide-dark-border rounded-xl border border-dark-border">{workstations.map(station => <div key={station.id} className="flex items-center gap-3 p-3"><span className="h-3 w-3 shrink-0 rounded-full" style={{ backgroundColor: station.color }} /><div className="min-w-0 flex-1"><p className="truncate text-sm font-bold text-dark-text">{station.name}</p><p className="text-xs font-semibold text-dark-muted">{station.code} · posição {station.sortOrder}{station.isActive ? '' : ' · inativo'}</p></div><button type="button" onClick={() => editStation(station)} className="inline-flex h-8 w-8 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 ${station.name}`}><Pencil className="h-3.5 w-3.5" /></button></div>)}</div></section>
</div>
</>}
</div>
);
};
export default ProductionQuality;

View File

@@ -1,6 +1,6 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Link, useSearchParams } from 'react-router-dom'; import { Link, useSearchParams } from 'react-router-dom';
import { ClipboardList, CloudDownload, Loader2, Package, Pencil, RefreshCw, Ruler, Save, Tags, Trash2, Upload } from 'lucide-react'; import { ChevronDown, ClipboardList, CloudDownload, Loader2, Package, Pencil, RefreshCw, Ruler, Save, Search, Tags, Trash2, Upload } from 'lucide-react';
import { import {
deleteCatalogCategory, deleteCatalogCategory,
deleteCatalogProduct, deleteCatalogProduct,
@@ -101,6 +101,84 @@ const getAverageYield = (reference: ConsumptionReference) => {
return reference.generalYield; return reference.generalYield;
}; };
const CatalogProductPicker = ({
value,
products,
emptyLabel,
searchPlaceholder,
onChange,
}: {
value: string;
products: CatalogProduct[];
emptyLabel: string;
searchPlaceholder: string;
onChange: (value: string) => void;
}) => {
const [isOpen, setIsOpen] = useState(false);
const [query, setQuery] = useState('');
const selectedProduct = products.find(product => String(product.id) === value);
const normalizedQuery = query.trim().toLocaleLowerCase('pt-BR');
const visibleProducts = useMemo(() => products
.filter(product => !normalizedQuery || `${product.name} ${product.sku} ${product.color || ''}`.toLocaleLowerCase('pt-BR').includes(normalizedQuery))
.slice(0, 60), [products, normalizedQuery]);
return (
<div className="relative mt-1">
<button
type="button"
aria-expanded={isOpen}
onClick={() => {
setIsOpen(current => !current);
setQuery('');
}}
className={`${inputClassName} flex items-center justify-between gap-2 text-left cursor-pointer`}
>
<span className={selectedProduct ? 'truncate text-dark-text' : 'truncate text-dark-muted'}>
{selectedProduct ? `${selectedProduct.name}${selectedProduct.sku ? ` (${selectedProduct.sku})` : ''}` : emptyLabel}
</span>
<ChevronDown className={`h-4 w-4 shrink-0 text-dark-muted transition-transform ${isOpen ? 'rotate-180' : ''}`} />
</button>
{isOpen && (
<div className="absolute z-30 mt-2 w-full overflow-hidden rounded-xl border border-dark-border bg-dark-card shadow-2xl">
<div className="border-b border-dark-border p-2">
<label className="relative block">
<Search className="pointer-events-none absolute left-3 top-3 h-4 w-4 text-dark-muted" />
<input
autoFocus
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder={searchPlaceholder}
className={`${inputClassName} pl-9`}
/>
</label>
</div>
<div className="max-h-60 overflow-y-auto p-1">
<button
type="button"
onClick={() => { onChange(''); setIsOpen(false); }}
className="flex w-full items-center rounded-lg px-3 py-2 text-left text-sm font-semibold text-dark-muted transition-colors hover:bg-dark-input hover:text-dark-text cursor-pointer"
>
{emptyLabel}
</button>
{visibleProducts.map(product => (
<button
key={product.id}
type="button"
onClick={() => { onChange(String(product.id)); setIsOpen(false); }}
className={`flex w-full flex-col rounded-lg px-3 py-2 text-left transition-colors hover:bg-dark-input cursor-pointer ${String(product.id) === value ? 'bg-brand-primary/10' : ''}`}
>
<span className="text-sm font-bold text-dark-text">{product.name}</span>
<span className="mt-0.5 text-xs font-semibold text-dark-muted">{[product.sku, product.color ? formatColorLabel(product.color) : ''].filter(Boolean).join(' · ')}</span>
</button>
))}
{!visibleProducts.length && <p className="px-3 py-4 text-sm font-semibold text-dark-muted">Nenhum item encontrado.</p>}
</div>
</div>
)}
</div>
);
};
const getReferenceSourceLabel = (reference: ConsumptionReference) => ( const getReferenceSourceLabel = (reference: ConsumptionReference) => (
reference.source === 'tiny_op' reference.source === 'tiny_op'
? 'Tiny OP' ? 'Tiny OP'
@@ -119,6 +197,7 @@ const Registrations = () => {
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [activeTab, setActiveTab] = useState<RegistrationTab>('products'); const [activeTab, setActiveTab] = useState<RegistrationTab>('products');
const [productFilter, setProductFilter] = useState<'all' | CatalogProductType>('all'); const [productFilter, setProductFilter] = useState<'all' | CatalogProductType>('all');
const [referenceSearch, setReferenceSearch] = useState('');
const [pagination, setPagination] = useState<RegistrationPagination>({ const [pagination, setPagination] = useState<RegistrationPagination>({
products: { page: 1, pageSize: 10 }, products: { page: 1, pageSize: 10 },
categories: { page: 1, pageSize: 10 }, categories: { page: 1, pageSize: 10 },
@@ -303,10 +382,20 @@ const Registrations = () => {
const categoryStartIndex = (safeCategoryPage - 1) * pagination.categories.pageSize; const categoryStartIndex = (safeCategoryPage - 1) * pagination.categories.pageSize;
const paginatedCategories = catalog.categories.slice(categoryStartIndex, categoryStartIndex + pagination.categories.pageSize); const paginatedCategories = catalog.categories.slice(categoryStartIndex, categoryStartIndex + pagination.categories.pageSize);
const referenceTotalPages = Math.ceil(catalog.consumptionReferences.length / pagination.references.pageSize); const filteredReferences = useMemo(() => {
const query = referenceSearch.trim().toLocaleLowerCase('pt-BR');
if (!query) return catalog.consumptionReferences;
return catalog.consumptionReferences.filter(reference => (
`${reference.productSku} ${reference.productName} ${reference.materialSku} ${reference.materialName} ${reference.color}`
.toLocaleLowerCase('pt-BR')
.includes(query)
));
}, [catalog.consumptionReferences, referenceSearch]);
const referenceTotalPages = Math.ceil(filteredReferences.length / pagination.references.pageSize);
const safeReferencePage = Math.min(pagination.references.page, referenceTotalPages || 1); const safeReferencePage = Math.min(pagination.references.page, referenceTotalPages || 1);
const referenceStartIndex = (safeReferencePage - 1) * pagination.references.pageSize; const referenceStartIndex = (safeReferencePage - 1) * pagination.references.pageSize;
const paginatedReferences = catalog.consumptionReferences.slice(referenceStartIndex, referenceStartIndex + pagination.references.pageSize); const paginatedReferences = filteredReferences.slice(referenceStartIndex, referenceStartIndex + pagination.references.pageSize);
const runAction = async (action: () => Promise<void>, successMessage: string) => { const runAction = async (action: () => Promise<void>, successMessage: string) => {
setStatus('saving'); setStatus('saving');
@@ -907,7 +996,7 @@ const Registrations = () => {
)} )}
{activeTab === 'references' && ( {activeTab === 'references' && (
<div className="grid grid-cols-1 gap-6 xl:grid-cols-[1fr_420px]"> <div className="grid grid-cols-1 gap-6 xl:grid-cols-2">
<div className={listPanelClassName}> <div className={listPanelClassName}>
<div className={listHeaderClassName}> <div className={listHeaderClassName}>
<div> <div>
@@ -915,9 +1004,23 @@ const Registrations = () => {
<p className="mt-1 text-xs font-semibold text-dark-muted">Rendimento pç/kg por produto, malha, cor e tamanho.</p> <p className="mt-1 text-xs font-semibold text-dark-muted">Rendimento pç/kg por produto, malha, cor e tamanho.</p>
</div> </div>
<span className="rounded-full border border-dark-border bg-dark-input px-3 py-1 text-xs font-bold text-dark-muted"> <span className="rounded-full border border-dark-border bg-dark-input px-3 py-1 text-xs font-bold text-dark-muted">
{catalog.consumptionReferences.length} referencias {referenceSearch ? `${filteredReferences.length} de ${catalog.consumptionReferences.length}` : catalog.consumptionReferences.length} referencias
</span> </span>
</div> </div>
<div className="border-b border-dark-border p-4">
<label className="relative block">
<Search className="pointer-events-none absolute left-3 top-3 h-4 w-4 text-dark-muted" />
<input
value={referenceSearch}
onChange={(event) => {
setReferenceSearch(event.target.value);
updatePagination('references', { page: 1 });
}}
placeholder="Buscar por produto, SKU ou material..."
className={`${inputClassName} pl-9`}
/>
</label>
</div>
<div className="divide-y divide-dark-border"> <div className="divide-y divide-dark-border">
{paginatedReferences.map(reference => ( {paginatedReferences.map(reference => (
<div key={reference.id} className="flex flex-col gap-3 p-4 md:flex-row md:items-start md:justify-between"> <div key={reference.id} className="flex flex-col gap-3 p-4 md:flex-row md:items-start md:justify-between">
@@ -976,14 +1079,14 @@ const Registrations = () => {
</div> </div>
</div> </div>
))} ))}
{!catalog.consumptionReferences.length && ( {!filteredReferences.length && (
<div className={emptyStateClassName}> <div className={emptyStateClassName}>
Nenhuma referencia cadastrada. {referenceSearch ? 'Nenhuma referência encontrada.' : 'Nenhuma referencia cadastrada.'}
</div> </div>
)} )}
</div> </div>
<PaginationControls <PaginationControls
totalItems={catalog.consumptionReferences.length} totalItems={filteredReferences.length}
currentPage={safeReferencePage} currentPage={safeReferencePage}
totalPages={referenceTotalPages} totalPages={referenceTotalPages}
pageSize={pagination.references.pageSize} pageSize={pagination.references.pageSize}
@@ -991,9 +1094,10 @@ const Registrations = () => {
itemLabel="referências" itemLabel="referências"
pageSizeLabel="por página" pageSizeLabel="por página"
startIndex={referenceStartIndex} startIndex={referenceStartIndex}
endIndex={Math.min(referenceStartIndex + pagination.references.pageSize, catalog.consumptionReferences.length)} endIndex={Math.min(referenceStartIndex + pagination.references.pageSize, filteredReferences.length)}
onPageChange={(page) => updatePagination('references', { page })} onPageChange={(page) => updatePagination('references', { page })}
onPageSizeChange={(pageSize) => updatePagination('references', { page: 1, pageSize })} onPageSizeChange={(pageSize) => updatePagination('references', { page: 1, pageSize })}
stacked
/> />
</div> </div>
@@ -1011,17 +1115,23 @@ const Registrations = () => {
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2"> <div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<label className={labelClassName}> <label className={labelClassName}>
Produto Produto
<select value={referenceForm.productId} onChange={(event) => setReferenceForm(current => ({ ...current, productId: event.target.value }))} className={inputClassName}> <CatalogProductPicker
<option value="">Selecione...</option> value={referenceForm.productId}
{finishedProducts.map(product => <option key={product.id} value={product.id}>{product.name} ({product.sku})</option>)} products={finishedProducts}
</select> emptyLabel="Selecione o produto"
searchPlaceholder="Buscar por nome ou SKU..."
onChange={(productId) => setReferenceForm(current => ({ ...current, productId }))}
/>
</label> </label>
<label className={labelClassName}> <label className={labelClassName}>
Malha Malha
<select value={referenceForm.materialProductId} onChange={(event) => setReferenceForm(current => ({ ...current, materialProductId: event.target.value }))} className={inputClassName}> <CatalogProductPicker
<option value="">Qualquer</option> value={referenceForm.materialProductId}
{rawMaterials.map(product => <option key={product.id} value={product.id}>{product.name}</option>)} products={rawMaterials}
</select> emptyLabel="Qualquer malha"
searchPlaceholder="Buscar malha por nome ou SKU..."
onChange={(materialProductId) => setReferenceForm(current => ({ ...current, materialProductId }))}
/>
</label> </label>
</div> </div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2"> <div className="grid grid-cols-1 gap-3 sm:grid-cols-2">

File diff suppressed because one or more lines are too long

View File

@@ -25,9 +25,33 @@ export interface StockData {
saldo: number; saldo: number;
delta_estoque: number; delta_estoque: number;
updated_at?: string; updated_at?: string;
graphs_balance?: number | null;
graphs_balance_reason?: string | null;
graphs_balance_effective_at?: string | null;
graphs_balance_updated_by?: string | null;
graphs_balance_updated_at?: string | null;
planning_balance?: number;
} }
export type ProductionOrderStatus = 'open' | 'in_progress' | 'finished' | 'canceled' | string; export type GraphsStockBalancePayload = {
balance: number;
reason: string;
effectiveAt?: string | null;
};
export type GraphsStockBalanceHistoryItem = {
id: number;
produto_id: string;
tiny_balance: number | null;
graphs_balance: number | null;
reason: string | null;
effective_at: string | null;
action: 'set' | 'removed' | string;
changed_by: string | null;
created_at: string;
};
export type ProductionOrderStatus = 'open' | 'cutting' | 'sewing' | 'review' | 'finished' | 'canceled' | string;
export interface ProductionOrderMarker { export interface ProductionOrderMarker {
label: string; label: string;
@@ -54,12 +78,67 @@ export interface ProductionOrderStep {
color: string; color: string;
} }
export interface ProductionOrderLotReservation {
id: number;
lotId: number;
materialKey: string;
quantity: number;
consumedQuantity: number;
status: string;
lotProduct: string;
lotUnit: string;
lotSupplier: string;
}
export interface ProductionWorkstation {
id: number;
code: string;
name: string;
color: string;
sortOrder: number;
isActive: boolean;
}
export type ProductionWorkstationPayload = {
code?: string;
name: string;
color?: string;
sortOrder?: number;
isActive?: boolean;
};
export interface ProductionOrderDefect {
id: number;
workstationId: number | null;
workstationName: string;
defectType: string;
origin: string;
quantity: number;
lotCode: string;
notes: string;
occurredAt: string | null;
}
export type ProductionOrderDefectPayload = {
workstationId?: number | null;
defectType: string;
origin?: string;
quantity: number;
lotCode?: string;
notes?: string;
};
export interface ProductionOrderItem { export interface ProductionOrderItem {
id: number; id: number;
tinyId: string; tinyId: string;
number: string; number: string;
status: ProductionOrderStatus; status: ProductionOrderStatus;
statusLabel: string; statusLabel: string;
productionPlanId: number | null;
productionPlanNumber: string;
productionPlanName: string;
productionPlanStatus: string;
contractor: string;
orderReference: string; orderReference: string;
issueDate: string | null; issueDate: string | null;
expectedDate: string | null; expectedDate: string | null;
@@ -76,6 +155,14 @@ export interface ProductionOrderItem {
fabricKg: number | null; fabricKg: number | null;
ribKg: number | null; ribKg: number | null;
yieldPiecesPerKg: number | null; yieldPiecesPerKg: number | null;
actualCutQuantity: number | null;
rejectedQuantity: number;
finishedQuantity: number | null;
qualityNotes: string;
cutLayers: number | null;
markerReference: string;
defects: ProductionOrderDefect[];
lotReservations: ProductionOrderLotReservation[];
markers: ProductionOrderMarker[]; markers: ProductionOrderMarker[];
components: ProductionOrderComponent[]; components: ProductionOrderComponent[];
steps: ProductionOrderStep[]; steps: ProductionOrderStep[];
@@ -86,7 +173,9 @@ export interface ProductionOrderItem {
export interface ProductionOrderCounts { export interface ProductionOrderCounts {
all: number; all: number;
open: number; open: number;
in_progress: number; cutting: number;
sewing: number;
review: number;
finished: number; finished: number;
canceled: number; canceled: number;
[key: string]: number; [key: string]: number;
@@ -113,10 +202,95 @@ export type ProductionOrderPayload = {
quantity: number; quantity: number;
unit?: string; unit?: string;
integrationStatus?: string; integrationStatus?: string;
contractor?: string;
components?: Array<{
componentTinyId?: string;
componentSku?: string;
componentName: string;
quantityPerUnit: number;
totalQuantity?: number;
unit?: string;
}>;
markers?: ProductionOrderMarkerPayload[]; markers?: ProductionOrderMarkerPayload[];
metadata?: Record<string, unknown>; metadata?: Record<string, unknown>;
}; };
export interface ProductionPlan {
id: number;
number: string;
name: string;
status: ProductionOrderStatus;
statusLabel: string;
contractor: string;
responsibleUserId: number | null;
responsibleName: string;
plannedStartDate: string | null;
expectedDate: string | null;
completedAt: string | null;
notes: string;
source: string;
orderCount: number;
totalQuantity: number;
actualCutQuantity: number;
finishedQuantity: number;
statusCounts: Record<string, number>;
review: ProductionPlanReview | null;
createdAt: string | null;
updatedAt: string | null;
}
export type ProductionPlanReviewDecision = 'pending' | 'approved' | 'adjustments_required';
export interface ProductionPlanReview {
id: number;
decision: ProductionPlanReviewDecision;
checklist: {
materialsReserved?: boolean;
cutSpecificationReady?: boolean;
sizeAndColorReviewed?: boolean;
responsibleAndDateDefined?: boolean;
};
notes: string;
reviewedBy: string;
reviewedAt: string | null;
updatedAt: string | null;
}
export type ProductionPlanReviewPayload = {
decision: ProductionPlanReviewDecision;
checklist: ProductionPlanReview['checklist'];
notes?: string;
reviewedBy?: string;
};
export type ProductionPlanPayload = {
id?: number;
name: string;
contractor?: string;
responsibleUserId?: number | null;
responsibleName?: string;
plannedStartDate?: string | null;
expectedDate?: string | null;
notes?: string;
source?: string;
};
export type CutPlanLotReservationPayload = {
lotId: number;
materialKey: string;
quantity: number;
cutPlanReference?: string;
};
export type ProductionOrderExecutionPayload = {
actualCutQuantity?: number | null;
rejectedQuantity?: number | null;
finishedQuantity?: number | null;
qualityNotes?: string;
cutLayers?: number | null;
markerReference?: string;
};
export interface CreateProductionOrdersResult { export interface CreateProductionOrdersResult {
created: ProductionOrderItem[]; created: ProductionOrderItem[];
skipped: Array<{ skipped: Array<{
@@ -124,9 +298,10 @@ export interface CreateProductionOrdersResult {
productDescription?: string; productDescription?: string;
reason: string; reason: string;
}>; }>;
plan: ProductionPlan | null;
} }
export type CutFamilyKey = 'BLCS' | 'BLOS' | 'BLMC' | 'BLPM' | 'OUTROS'; export type CutFamilyKey = 'BLCS' | 'BLOS' | 'BLMC' | 'BLPM' | 'BLRM' | 'OUTROS';
export interface CutProductOverride { export interface CutProductOverride {
familyKey?: CutFamilyKey | ''; familyKey?: CutFamilyKey | '';
@@ -254,7 +429,12 @@ export interface OlistStatus {
pending: number; pending: number;
manufactured: number; manufactured: number;
}; };
latestRun: OlistSyncRun | null;
hasCompletedRun: boolean;
runs: OlistSyncRun[]; runs: OlistSyncRun[];
runsTotal: number;
runsPage: number;
runsPageSize: number;
} }
export interface ProductionOrderImportRun { export interface ProductionOrderImportRun {
@@ -272,7 +452,7 @@ export interface ProductionOrderImportRun {
} }
export interface ProductionYieldRecommendation { export interface ProductionYieldRecommendation {
familyKey: 'BLCS' | 'BLOS' | 'BLMC' | 'BLPM'; familyKey: 'BLCS' | 'BLOS' | 'BLMC' | 'BLPM' | 'BLRM';
sampleCount: number; sampleCount: number;
yieldSampleCount: number; yieldSampleCount: number;
rollSampleCount: number; rollSampleCount: number;
@@ -283,9 +463,17 @@ export interface ProductionYieldRecommendation {
updatedAt: string; updatedAt: string;
} }
export interface ProductionYieldSupplierRecommendation extends ProductionYieldRecommendation {
supplier: string;
}
export interface ProductionOrderImportData { export interface ProductionOrderImportData {
runs: ProductionOrderImportRun[]; runs: ProductionOrderImportRun[];
runsTotal: number;
runsPage: number;
runsPageSize: number;
recommendations: ProductionYieldRecommendation[]; recommendations: ProductionYieldRecommendation[];
supplierRecommendations: ProductionYieldSupplierRecommendation[];
} }
export type CatalogProductPayload = { export type CatalogProductPayload = {
@@ -330,6 +518,9 @@ export interface SupplyReceipt {
invoice: string; invoice: string;
notes: string; notes: string;
status: SupplyReceiptStatus; status: SupplyReceiptStatus;
purchaseOrderId: number | null;
purchaseOrderNumber: string;
purchaseOrderStatus: string;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
approvedAt: string | null; approvedAt: string | null;
@@ -345,6 +536,8 @@ export interface SupplyLot {
supplier: string; supplier: string;
invoice: string; invoice: string;
status: string; status: string;
reservedQuantity: number;
availableQuantity: number;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
} }
@@ -374,6 +567,57 @@ export interface SupplyFabricPlan {
updatedAt: string; updatedAt: string;
} }
export type MaterialTransformationStage = 'knitting' | 'dyeing' | 'finishing';
export type MaterialTransformationStatus = 'planned' | 'in_progress' | 'completed' | 'canceled';
export interface SupplyMaterialTransformation {
id: number;
batchCode: string;
stage: MaterialTransformationStage;
inputMaterial: string;
inputQuantityKg: number;
outputMaterial: string;
outputColor: string;
expectedLossPercent: number;
expectedOutputKg: number;
supplier: string;
priority: string;
status: MaterialTransformationStatus;
notes: string;
createdAt: string;
updatedAt: string;
}
export type SupplyPurchaseOrderStatus = 'draft' | 'pending_financial' | 'sent' | 'approved' | 'received' | 'canceled';
export interface SupplyPurchaseOrderItem {
id: number;
material: string;
quantity: number;
unit: string;
unitCost: number | null;
}
export interface SupplyPurchaseOrder {
id: number;
number: string;
supplier: string;
status: SupplyPurchaseOrderStatus;
expectedDate: string | null;
notes: string;
total: number;
items: SupplyPurchaseOrderItem[];
createdAt: string;
updatedAt: string;
}
export type SupplyPurchaseOrderPayload = {
supplier: string;
expectedDate?: string | null;
notes?: string;
items: Array<{ material: string; quantity: number; unit?: string; unitCost?: number | null }>;
};
export interface SupplyPurchaseNeed { export interface SupplyPurchaseNeed {
material: string; material: string;
materialKey: string; materialKey: string;
@@ -444,6 +688,19 @@ export type SupplyFabricPlanPayload = {
priority?: string; priority?: string;
}; };
export type SupplyMaterialTransformationPayload = {
batchCode?: string;
stage: MaterialTransformationStage;
inputMaterial: string;
inputQuantityKg: number | string;
outputMaterial: string;
outputColor?: string;
expectedLossPercent?: number | string;
supplier?: string;
priority?: string;
notes?: string;
};
export type SupplyInventoryAdjustmentPayload = { export type SupplyInventoryAdjustmentPayload = {
countedQuantity: number | string; countedQuantity: number | string;
reason: string; reason: string;
@@ -478,6 +735,12 @@ export interface ManagedUser {
updatedAt: string; updatedAt: string;
} }
export interface OperationalUser {
id: number | null;
name: string;
isSystem?: boolean;
}
export interface CreateUserResult { export interface CreateUserResult {
user: ManagedUser; user: ManagedUser;
temporaryPassword?: string; temporaryPassword?: string;
@@ -591,6 +854,19 @@ export interface ProductComposition {
components: ProductCompositionComponent[]; components: ProductCompositionComponent[];
} }
export interface ProductCompositionPayload {
productSku: string;
productDescription: string;
productUnit: string;
components: Array<{
componentTinyId?: string;
componentSku?: string;
componentName: string;
quantityPerUnit: number;
unit: string;
}>;
}
export interface ProductCompositionImportSummary { export interface ProductCompositionImportSummary {
imported: number; imported: number;
failed: number; failed: number;