Compare commits
14 Commits
7e18772a8a
...
3a0cc9ffe8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3a0cc9ffe8 | ||
|
|
c259ddea29 | ||
|
|
d14f7e315f | ||
|
|
cdeb3dcc59 | ||
|
|
03b6e2fef7 | ||
|
|
5370dffce2 | ||
|
|
282f1e194a | ||
|
|
7eedbded2d | ||
|
|
85afba4e5b | ||
|
|
21d9960145 | ||
|
|
91f3e5e00c | ||
|
|
a226e6d9f4 | ||
|
|
713cc4a9fc | ||
|
|
11443a9202 |
74
CONTEXT.md
74
CONTEXT.md
@@ -1,7 +1,7 @@
|
||||
# Context
|
||||
|
||||
## 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
|
||||
|
||||
@@ -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.
|
||||
* **Route Code Splitting:** Frontend routes are lazy-loaded with `React.lazy`/`Suspense` so the first JS payload stays smaller. Recharts-heavy page chunks are loaded on demand.
|
||||
* **Analytics Client Cache:** Frontend analytics requests use a small shared cache/deduping layer in `src/dataService.ts`. Navigating between Dashboard, RFV, Products, and Clients should reuse fresh responses instead of refetching every route mount unless query params change or the cache expires.
|
||||
* **Tiny as Current Source, Graphs as Future Operating Layer:** Tiny ERP is the current source for sales, product/order metadata, and stock. Graphs should increasingly become the place where the business manages production intelligence: cut configuration, SKU families, raw material relationships, consumption references, and operational charts. `NECESSIDADE DE CORTE.xlsx` is reference material only and must not become a runtime dependency.
|
||||
* **Tiny/Olist Composition Import:** Product structures from Tiny/Olist V3 are imported into `product_compositions` and `product_composition_components`. Sellable finished-product structures are also promoted into local catalog products/materials and `consumption_references` with source `tiny_structure`, so purchase planning can use real component consumption instead of guessed kg-per-piece rules.
|
||||
* **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 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
|
||||
```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 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.
|
||||
* **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.
|
||||
* **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 ...`.
|
||||
@@ -112,7 +113,7 @@ yield: 4.8 units/kg
|
||||
* **Finished SKU catalog:** SKU/id, name, color, size, product family, cut family, active/inactive.
|
||||
* **Raw material catalog:** material SKU/id, name, type (`malha`, `ribana`, `fio`, etc.), color, supplier, unit (`kg`, `metro`, `unidade`), and whether it is raw input instead of finished stock.
|
||||
* **Consumption references:** finished SKU, material product, yield per kg, optional yield by size, optional color/family overrides, and secondary materials when needed.
|
||||
* **Imported product compositions:** Tiny/Olist structures are stored as immutable-ish sync facts: finished SKU/Tiny ID/unit, component SKU/Tiny ID/name, quantity per finished unit, and component unit (`KG`, `UN`, etc.). These records support Product Details composition display and drive `tiny_structure` consumption references.
|
||||
* **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.
|
||||
* **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.
|
||||
@@ -120,14 +121,20 @@ yield: 4.8 units/kg
|
||||
**Current UI/data-flow behavior:**
|
||||
* `Cadastros` has an `Importar composições` JSON upload action for exports like `composicoes_produtos_YYYY-MM-DD.json`. The same import is available to scripts through `POST /api/production-orders/tiny-compositions/import` using `x-api-key`.
|
||||
* Composition imports are idempotent. Re-importing the same Tiny/Olist export updates composition rows/components and refreshes derived `tiny_structure` consumption references instead of duplicating them.
|
||||
* `Product Details > Composição` shows the synced component list for the current product when a composition exists.
|
||||
* `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.
|
||||
* Imported `tiny_structure` consumption references now allow `Necessidade de Compra` to calculate mixed-unit material demand from real BOM lines, e.g. malha/ribana in `KG` and etiqueta/ilhós/atacador in `UN`.
|
||||
* Missing-reference rows link into `Cadastros > Referência de Consumo` with SKU context. If the SKU does not exist in the local catalog yet, `Cadastros` opens the product form first.
|
||||
* `Cadastros > Referência de Consumo` labels reference sources as `Manual`, `Tiny OP`, or `Tiny Estrutura`.
|
||||
* `Plano de Corte` SKU edit actions deep-link into the cutting configuration drawer for that SKU.
|
||||
* `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.
|
||||
|
||||
**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:**
|
||||
* 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.
|
||||
@@ -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.
|
||||
* 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 a Super Admin **Olist** monitor at `/#/admin/olist`. The connection action, manual sync, reconnection, stop action, live status, run history, run logs, and affected-product view are all on this page; Cadastros does not own the Olist connection flow.
|
||||
* 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.
|
||||
* **Docker Registry:** The pipeline builds the `frontend` and `backend` Docker images and pushes them directly to `gitea.blyzer.com.br/blyzer/`.
|
||||
* **Production Deployment:** Updates are deployed manually via Portainer by pulling the `latest` image tags from the Gitea registry and redeploying the stack.
|
||||
* **Environment Variables:** Security secrets (`API_KEY`, `JWT_SECRET`, `POSTGRES_PASSWORD`, `N8N_WHATSAPP_TRIGGER_URL`) are injected via the Portainer stack configuration and passed into containers via `docker-compose.yml`.
|
||||
|
||||
## 8. Environment Setup & Scripts
|
||||
## 9. Environment Setup & Scripts
|
||||
|
||||
**Running Locally:**
|
||||
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`.
|
||||
|
||||
## 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`.
|
||||
* **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`.
|
||||
@@ -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.
|
||||
* **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:
|
||||
* `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.
|
||||
@@ -236,7 +284,7 @@ Files most relevant to RFV:
|
||||
Recent commits related to supply/cutting/data-flow and chart readability:
|
||||
* `37d4a77 Import Tiny product compositions` - adds the Tiny/Olist composition JSON import flow, derived `tiny_structure` consumption references, app upload button, API-key import endpoint, and import tests.
|
||||
* `800eb97 Derive consumption references from Tiny OP sync` - Tiny OP composition sync now creates catalog products/materials and reusable consumption references from OP component lines.
|
||||
* `c07938c Add Tiny production order detail sync` - stores OP detail rows, components, steps, and observations from Tiny/Olist sync payloads.
|
||||
* `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.
|
||||
* `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.
|
||||
@@ -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/pages/Products.tsx` and `src/pages/ProductGroupDetails.tsx` - product/group velocity, stock coverage, and high-volume display.
|
||||
* `src/chartUtils.ts` - shared date bucketing, moving average, unknown-label cleanup, and chart date helpers.
|
||||
* `backend/services/productionOrderService.js` - Tiny OP detail sync, Tiny/Olist structure import, composition storage, sellable structure filtering, and derived consumption-reference creation.
|
||||
* `backend/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/test/productCompositionService.test.js` - coverage for Tiny/Olist structure sync and bulk composition import behavior.
|
||||
|
||||
130
backend/db.js
130
backend/db.js
@@ -170,6 +170,31 @@ 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
|
||||
);
|
||||
`);
|
||||
|
||||
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(`
|
||||
CREATE TABLE IF NOT EXISTS production_order_markers (
|
||||
id SERIAL PRIMARY KEY,
|
||||
@@ -211,6 +236,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(`
|
||||
CREATE TABLE IF NOT EXISTS product_compositions (
|
||||
id SERIAL PRIMARY KEY,
|
||||
@@ -443,6 +505,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(`
|
||||
CREATE TABLE IF NOT EXISTS supply_movements (
|
||||
id SERIAL PRIMARY KEY,
|
||||
@@ -472,6 +559,33 @@ const initDB = async () => {
|
||||
);
|
||||
`);
|
||||
|
||||
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 production_orders
|
||||
ADD COLUMN IF NOT EXISTS notes TEXT,
|
||||
@@ -488,6 +602,14 @@ const initDB = async () => {
|
||||
ADD COLUMN IF NOT EXISTS completed_date DATE;
|
||||
`).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;
|
||||
`).catch(() => {});
|
||||
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS production_order_import_runs (
|
||||
id SERIAL PRIMARY KEY,
|
||||
@@ -670,6 +792,14 @@ const initDB = async () => {
|
||||
ALTER COLUMN updated_at SET DEFAULT CURRENT_TIMESTAMP;
|
||||
`).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(`
|
||||
ALTER TABLE stock_campaign_queue
|
||||
ALTER COLUMN created_at TYPE TIMESTAMPTZ USING created_at AT TIME ZONE 'America/Sao_Paulo',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
const express = require('express');
|
||||
const { authenticateAPIKey, verifyToken } = require('../auth');
|
||||
const { createProductionOrders, importTinyProductCompositionExport, listProductionOrders, updateProductionOrderStatus, upsertTinyProductionOrderDetail } = require('../services/productionOrderService');
|
||||
const { addProductionOrderDefect, createProductionOrders, importTinyProductCompositionExport, listProductionOrders, listProductionPlans, listProductionWorkstations, updateProductionOrderExecution, updateProductionOrderStatus, updateProductionPlan, updateProductionPlanStatus, upsertTinyProductionOrderDetail } = require('../services/productionOrderService');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
@@ -16,13 +16,49 @@ router.get('/production-orders', verifyToken, async (req, res) => {
|
||||
router.post('/production-orders', verifyToken, async (req, res) => {
|
||||
try {
|
||||
const orders = Array.isArray(req.body?.orders) ? req.body.orders : [];
|
||||
res.status(201).json(await createProductionOrders(orders));
|
||||
const lotReservations = Array.isArray(req.body?.lotReservations) ? req.body.lotReservations : [];
|
||||
res.status(201).json(await createProductionOrders(orders, lotReservations, req.body?.plan || null));
|
||||
} catch (error) {
|
||||
console.error('Error creating production orders:', 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());
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: '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.post('/production-orders/tiny-sync', authenticateAPIKey, async (req, res) => {
|
||||
try {
|
||||
res.status(201).json(await upsertTinyProductionOrderDetail(req.body || {}));
|
||||
@@ -50,4 +86,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;
|
||||
|
||||
@@ -5,12 +5,16 @@ const {
|
||||
approveReceipt,
|
||||
consumeLotForProduction,
|
||||
createFabricPlan,
|
||||
createPurchaseOrder,
|
||||
createReceipt,
|
||||
deleteFabricPlan,
|
||||
deleteReceipt,
|
||||
getSupplySummary,
|
||||
listFabricPlans,
|
||||
listKnownSuppliers,
|
||||
listPurchaseOrders,
|
||||
saveMaterialDetails,
|
||||
updatePurchaseOrderStatus,
|
||||
listLots,
|
||||
listMovements,
|
||||
listPurchaseNeeds,
|
||||
@@ -27,6 +31,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) => {
|
||||
try {
|
||||
res.json(await listReceipts());
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
const express = require('express');
|
||||
const { verifySuperAdmin } = require('../auth');
|
||||
const { createUser, deleteUser, listUsers, updateUser } = require('../services/userService');
|
||||
const { verifySuperAdmin, verifyToken } = require('../auth');
|
||||
const { createUser, deleteUser, listActiveOperationalUsers, listUsers, updateUser } = require('../services/userService');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.get('/users/operational', verifyToken, async (req, res, next) => {
|
||||
try {
|
||||
res.json({ users: await listActiveOperationalUsers() });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/users', verifySuperAdmin, async (req, res, next) => {
|
||||
try {
|
||||
const users = await listUsers();
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
const { pool } = require('../db');
|
||||
const { getActiveOperationalUserById, SYSTEM_OPERATIONAL_USER_NAME } = require('./userService');
|
||||
|
||||
const STATUS_LABELS = {
|
||||
open: 'Em aberto',
|
||||
in_progress: 'Em andamento',
|
||||
cutting: 'Em corte',
|
||||
sewing: 'Em costura',
|
||||
review: 'Em revisão',
|
||||
finished: 'Finalizada',
|
||||
canceled: 'Cancelada'
|
||||
};
|
||||
@@ -10,7 +13,9 @@ const STATUS_LABELS = {
|
||||
const normalizeStatus = (status) => {
|
||||
const normalizedStatus = String(status || 'open').trim().toLowerCase();
|
||||
if (['open', 'em_aberto', 'em aberto', 'aberta'].includes(normalizedStatus)) return 'open';
|
||||
if (['in_progress', 'andamento', 'em andamento'].includes(normalizedStatus)) return 'in_progress';
|
||||
if (['in_progress', 'andamento', 'em andamento', 'em corte', 'corte', 'cutting'].includes(normalizedStatus)) return 'cutting';
|
||||
if (['em costura', 'costura', 'sewing'].includes(normalizedStatus)) return 'sewing';
|
||||
if (['em revisao', 'em revisão', 'revisao', 'revisão', 'review'].includes(normalizedStatus)) return 'review';
|
||||
if (['finished', 'finalizada', 'finalizado'].includes(normalizedStatus)) return 'finished';
|
||||
if (['canceled', 'cancelada', 'cancelado', 'cancelled'].includes(normalizedStatus)) return 'canceled';
|
||||
return normalizedStatus || 'open';
|
||||
@@ -60,6 +65,15 @@ const normalizeNullableQuantity = (value) => {
|
||||
return quantity || null;
|
||||
};
|
||||
|
||||
const normalizeNonNegativeQuantity = (value) => {
|
||||
if (value === '' || value === null || value === undefined) return null;
|
||||
const normalizedValue = typeof value === 'string' && value.includes(',')
|
||||
? value.replace(/\./g, '').replace(',', '.')
|
||||
: value;
|
||||
const quantity = Number(normalizedValue);
|
||||
return Number.isFinite(quantity) && quantity >= 0 ? quantity : null;
|
||||
};
|
||||
|
||||
const normalizeInteger = (value) => {
|
||||
const number = Number(value);
|
||||
return Number.isInteger(number) ? number : null;
|
||||
@@ -242,6 +256,30 @@ const mapStep = (step) => ({
|
||||
color: step.color || ''
|
||||
});
|
||||
|
||||
const mapLotReservation = (reservation) => ({
|
||||
id: reservation.id,
|
||||
lotId: Number(reservation.supply_lot_id),
|
||||
materialKey: reservation.material_key || '',
|
||||
quantity: Number(reservation.quantity || 0),
|
||||
consumedQuantity: Number(reservation.consumed_quantity || 0),
|
||||
status: reservation.status || '',
|
||||
lotProduct: reservation.lot_product || '',
|
||||
lotUnit: reservation.lot_unit || '',
|
||||
lotSupplier: reservation.lot_supplier || ''
|
||||
});
|
||||
|
||||
const mapDefect = (defect) => ({
|
||||
id: Number(defect.id),
|
||||
workstationId: defect.workstation_id === null ? null : Number(defect.workstation_id),
|
||||
workstationName: defect.workstation_name || '',
|
||||
defectType: defect.defect_type || '',
|
||||
origin: defect.origin || '',
|
||||
quantity: Number(defect.quantity || 0),
|
||||
lotCode: defect.lot_code || '',
|
||||
notes: defect.notes || '',
|
||||
occurredAt: defect.occurred_at || null
|
||||
});
|
||||
|
||||
const mapProductionOrderRow = (row) => {
|
||||
const status = normalizeStatus(row.status);
|
||||
|
||||
@@ -251,6 +289,11 @@ const mapProductionOrderRow = (row) => {
|
||||
number: row.number || '',
|
||||
status,
|
||||
statusLabel: STATUS_LABELS[status] || row.status || 'Em aberto',
|
||||
productionPlanId: row.production_plan_id === null || row.production_plan_id === undefined ? null : Number(row.production_plan_id),
|
||||
productionPlanNumber: row.production_plan_number || '',
|
||||
productionPlanName: row.production_plan_name || '',
|
||||
productionPlanStatus: row.production_plan_status || '',
|
||||
contractor: row.contractor || '',
|
||||
orderReference: row.order_reference || '',
|
||||
issueDate: formatDate(row.issue_date),
|
||||
expectedDate: formatDate(row.expected_date),
|
||||
@@ -267,6 +310,12 @@ const mapProductionOrderRow = (row) => {
|
||||
fabricKg: row.fabric_kg === null ? null : Number(row.fabric_kg),
|
||||
ribKg: row.rib_kg === null ? null : Number(row.rib_kg),
|
||||
yieldPiecesPerKg: row.yield_pieces_per_kg === null ? null : Number(row.yield_pieces_per_kg),
|
||||
actualCutQuantity: row.actual_cut_quantity === null ? null : Number(row.actual_cut_quantity),
|
||||
rejectedQuantity: Number(row.rejected_quantity || 0),
|
||||
finishedQuantity: row.finished_quantity === null ? null : Number(row.finished_quantity),
|
||||
qualityNotes: row.quality_notes || '',
|
||||
defects: Array.isArray(row.defects) ? row.defects.map(mapDefect) : [],
|
||||
lotReservations: Array.isArray(row.lot_reservations) ? row.lot_reservations.map(mapLotReservation) : [],
|
||||
markers: Array.isArray(row.markers) ? row.markers.filter(Boolean) : [],
|
||||
components: Array.isArray(row.components) ? row.components.map(mapComponent) : [],
|
||||
steps: Array.isArray(row.steps) ? row.steps.map(mapStep) : [],
|
||||
@@ -275,6 +324,117 @@ const mapProductionOrderRow = (row) => {
|
||||
};
|
||||
};
|
||||
|
||||
const mapProductionPlanRow = (row) => ({
|
||||
id: Number(row.id),
|
||||
number: row.number || '',
|
||||
name: row.name || '',
|
||||
status: normalizeStatus(row.status),
|
||||
statusLabel: STATUS_LABELS[normalizeStatus(row.status)] || row.status || 'Em aberto',
|
||||
contractor: row.contractor || '',
|
||||
responsibleUserId: row.responsible_user_id === null || row.responsible_user_id === undefined ? null : Number(row.responsible_user_id),
|
||||
responsibleName: row.responsible_user_name || row.responsible_name || '',
|
||||
plannedStartDate: formatDate(row.planned_start_date),
|
||||
expectedDate: formatDate(row.expected_date),
|
||||
completedAt: row.completed_at || null,
|
||||
notes: row.notes || '',
|
||||
source: row.source || 'cut_plan',
|
||||
orderCount: Number(row.order_count || 0),
|
||||
totalQuantity: Number(row.total_quantity || 0),
|
||||
actualCutQuantity: Number(row.actual_cut_quantity || 0),
|
||||
finishedQuantity: Number(row.finished_quantity || 0),
|
||||
statusCounts: row.status_counts || {},
|
||||
createdAt: row.created_at || null,
|
||||
updatedAt: row.updated_at || null
|
||||
});
|
||||
|
||||
const normalizePlanPayload = (payload = {}, fallback = {}) => ({
|
||||
id: normalizeInteger(payload.id),
|
||||
name: normalizeText(payload.name) || normalizeText(fallback.name),
|
||||
contractor: normalizeText(payload.contractor),
|
||||
responsibleUserId: payload.responsibleUserId === undefined
|
||||
? normalizeInteger(fallback.responsibleUserId)
|
||||
: normalizeInteger(payload.responsibleUserId),
|
||||
responsibleName: normalizeText(payload.responsibleName) || normalizeText(fallback.responsibleName),
|
||||
plannedStartDate: normalizeDateParam(payload.plannedStartDate || fallback.plannedStartDate),
|
||||
expectedDate: normalizeDateParam(payload.expectedDate || fallback.expectedDate),
|
||||
notes: normalizeText(payload.notes),
|
||||
source: normalizeText(payload.source) || 'cut_plan'
|
||||
});
|
||||
|
||||
const getProductionPlanById = async (id, client = pool) => {
|
||||
const result = await client.query(`
|
||||
SELECT
|
||||
plan.id, plan.number, plan.name, plan.status, plan.contractor, plan.responsible_user_id,
|
||||
responsible.name AS responsible_user_name, plan.responsible_name,
|
||||
plan.planned_start_date, plan.expected_date, plan.completed_at,
|
||||
plan.notes, plan.source, plan.created_at, plan.updated_at,
|
||||
COUNT(po.id)::int AS order_count,
|
||||
COALESCE(SUM(po.quantity), 0) AS total_quantity,
|
||||
COALESCE(SUM(po.actual_cut_quantity), 0) AS actual_cut_quantity,
|
||||
COALESCE(SUM(po.finished_quantity), 0) AS finished_quantity,
|
||||
COALESCE((
|
||||
SELECT JSON_OBJECT_AGG(status, count)
|
||||
FROM (
|
||||
SELECT status, COUNT(*)::int AS count
|
||||
FROM production_orders
|
||||
WHERE production_plan_id = plan.id
|
||||
GROUP BY status
|
||||
) grouped_statuses
|
||||
), '{}'::json) AS status_counts
|
||||
FROM production_plans plan
|
||||
LEFT JOIN production_orders po ON po.production_plan_id = plan.id
|
||||
LEFT JOIN app_users responsible ON responsible.id = plan.responsible_user_id
|
||||
WHERE plan.id = $1
|
||||
GROUP BY plan.id, responsible.id;
|
||||
`, [id]);
|
||||
return result.rows[0] ? mapProductionPlanRow(result.rows[0]) : null;
|
||||
};
|
||||
|
||||
const resolvePlanResponsible = async (plan, client = pool) => {
|
||||
if (plan.responsibleUserId) {
|
||||
const user = await getActiveOperationalUserById(plan.responsibleUserId, client);
|
||||
if (!user) {
|
||||
const error = new Error('O responsável selecionado não está disponível.');
|
||||
error.statusCode = 400;
|
||||
throw error;
|
||||
}
|
||||
return { userId: Number(user.id), name: user.name };
|
||||
}
|
||||
|
||||
if (plan.responsibleName && plan.responsibleName !== SYSTEM_OPERATIONAL_USER_NAME) {
|
||||
const error = new Error('O responsável selecionado não está disponível.');
|
||||
error.statusCode = 400;
|
||||
throw error;
|
||||
}
|
||||
|
||||
return { userId: null, name: plan.responsibleName };
|
||||
};
|
||||
|
||||
const createOrGetProductionPlan = async (client, payload, fallback) => {
|
||||
const plan = normalizePlanPayload(payload, fallback);
|
||||
if (plan.id) {
|
||||
const existing = await getProductionPlanById(plan.id, client);
|
||||
if (!existing) {
|
||||
const error = new Error('Plano de produção não encontrado.');
|
||||
error.statusCode = 404;
|
||||
throw error;
|
||||
}
|
||||
return existing;
|
||||
}
|
||||
if (!plan.name) return null;
|
||||
|
||||
const responsible = await resolvePlanResponsible(plan, client);
|
||||
|
||||
const inserted = await client.query(`
|
||||
INSERT INTO production_plans (name, status, contractor, responsible_user_id, responsible_name, planned_start_date, expected_date, notes, source)
|
||||
VALUES ($1, 'open', NULLIF($2, ''), $3, NULLIF($4, ''), $5, $6, NULLIF($7, ''), $8)
|
||||
RETURNING id;
|
||||
`, [plan.name, plan.contractor, responsible.userId, responsible.name, plan.plannedStartDate, plan.expectedDate, plan.notes, plan.source]);
|
||||
const id = Number(inserted.rows[0].id);
|
||||
await client.query(`UPDATE production_plans SET number = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2;`, [`PP-${String(id).padStart(5, '0')}`, id]);
|
||||
return getProductionPlanById(id, client);
|
||||
};
|
||||
|
||||
const getOrderById = async (id, client = pool) => {
|
||||
const result = await client.query(`
|
||||
SELECT
|
||||
@@ -282,6 +442,11 @@ const getOrderById = async (id, client = pool) => {
|
||||
po.tiny_id,
|
||||
po.number,
|
||||
po.status,
|
||||
po.production_plan_id,
|
||||
plan.number AS production_plan_number,
|
||||
plan.name AS production_plan_name,
|
||||
plan.status AS production_plan_status,
|
||||
po.contractor,
|
||||
po.order_reference,
|
||||
po.issue_date,
|
||||
po.expected_date,
|
||||
@@ -298,6 +463,10 @@ const getOrderById = async (id, client = pool) => {
|
||||
po.fabric_kg,
|
||||
po.rib_kg,
|
||||
po.yield_pieces_per_kg,
|
||||
po.actual_cut_quantity,
|
||||
po.rejected_quantity,
|
||||
po.finished_quantity,
|
||||
po.quality_notes,
|
||||
po.created_at,
|
||||
po.updated_at,
|
||||
COALESCE(
|
||||
@@ -333,6 +502,32 @@ const getOrderById = async (id, client = pool) => {
|
||||
),
|
||||
'[]'::json
|
||||
) as components,
|
||||
COALESCE(
|
||||
(
|
||||
SELECT JSON_AGG(
|
||||
JSON_BUILD_OBJECT(
|
||||
'id', reservation.id,
|
||||
'supply_lot_id', reservation.supply_lot_id,
|
||||
'material_key', reservation.material_key,
|
||||
'quantity', reservation.quantity,
|
||||
'consumed_quantity', reservation.consumed_quantity,
|
||||
'status', reservation.status,
|
||||
'lot_product', lot.product,
|
||||
'lot_unit', lot.unit,
|
||||
'lot_supplier', lot.supplier
|
||||
)
|
||||
ORDER BY reservation.created_at, reservation.id
|
||||
)
|
||||
FROM cut_plan_lot_reservations reservation
|
||||
JOIN supply_stock_lots lot ON lot.id = reservation.supply_lot_id
|
||||
WHERE (
|
||||
reservation.production_plan_id = po.production_plan_id
|
||||
OR (reservation.production_plan_id IS NULL AND reservation.cut_plan_reference = po.order_reference)
|
||||
)
|
||||
AND reservation.status IN ('active', 'consumed')
|
||||
),
|
||||
'[]'::json
|
||||
) as lot_reservations,
|
||||
COALESCE(
|
||||
(
|
||||
SELECT JSON_AGG(
|
||||
@@ -351,8 +546,28 @@ const getOrderById = async (id, client = pool) => {
|
||||
WHERE step.production_order_id = po.id
|
||||
),
|
||||
'[]'::json
|
||||
) as steps
|
||||
) as steps,
|
||||
COALESCE(
|
||||
(
|
||||
SELECT JSON_AGG(JSON_BUILD_OBJECT(
|
||||
'id', defect.id,
|
||||
'workstation_id', defect.workstation_id,
|
||||
'workstation_name', workstation.name,
|
||||
'defect_type', defect.defect_type,
|
||||
'origin', defect.origin,
|
||||
'quantity', defect.quantity,
|
||||
'lot_code', defect.lot_code,
|
||||
'notes', defect.notes,
|
||||
'occurred_at', defect.occurred_at
|
||||
) ORDER BY defect.occurred_at DESC, defect.id DESC)
|
||||
FROM production_order_defects defect
|
||||
LEFT JOIN production_workstations workstation ON workstation.id = defect.workstation_id
|
||||
WHERE defect.production_order_id = po.id
|
||||
),
|
||||
'[]'::json
|
||||
) as defects
|
||||
FROM production_orders po
|
||||
LEFT JOIN production_plans plan ON plan.id = po.production_plan_id
|
||||
WHERE po.id = $1;
|
||||
`, [id]);
|
||||
|
||||
@@ -365,6 +580,11 @@ const baseProductionOrderSelect = `
|
||||
po.tiny_id,
|
||||
po.number,
|
||||
po.status,
|
||||
po.production_plan_id,
|
||||
plan.number AS production_plan_number,
|
||||
plan.name AS production_plan_name,
|
||||
plan.status AS production_plan_status,
|
||||
po.contractor,
|
||||
po.order_reference,
|
||||
po.issue_date,
|
||||
po.expected_date,
|
||||
@@ -381,6 +601,10 @@ const baseProductionOrderSelect = `
|
||||
po.fabric_kg,
|
||||
po.rib_kg,
|
||||
po.yield_pieces_per_kg,
|
||||
po.actual_cut_quantity,
|
||||
po.rejected_quantity,
|
||||
po.finished_quantity,
|
||||
po.quality_notes,
|
||||
po.created_at,
|
||||
po.updated_at,
|
||||
COALESCE(
|
||||
@@ -416,6 +640,32 @@ const baseProductionOrderSelect = `
|
||||
),
|
||||
'[]'::json
|
||||
) as components,
|
||||
COALESCE(
|
||||
(
|
||||
SELECT JSON_AGG(
|
||||
JSON_BUILD_OBJECT(
|
||||
'id', reservation.id,
|
||||
'supply_lot_id', reservation.supply_lot_id,
|
||||
'material_key', reservation.material_key,
|
||||
'quantity', reservation.quantity,
|
||||
'consumed_quantity', reservation.consumed_quantity,
|
||||
'status', reservation.status,
|
||||
'lot_product', lot.product,
|
||||
'lot_unit', lot.unit,
|
||||
'lot_supplier', lot.supplier
|
||||
)
|
||||
ORDER BY reservation.created_at, reservation.id
|
||||
)
|
||||
FROM cut_plan_lot_reservations reservation
|
||||
JOIN supply_stock_lots lot ON lot.id = reservation.supply_lot_id
|
||||
WHERE (
|
||||
reservation.production_plan_id = po.production_plan_id
|
||||
OR (reservation.production_plan_id IS NULL AND reservation.cut_plan_reference = po.order_reference)
|
||||
)
|
||||
AND reservation.status IN ('active', 'consumed')
|
||||
),
|
||||
'[]'::json
|
||||
) as lot_reservations,
|
||||
COALESCE(
|
||||
(
|
||||
SELECT JSON_AGG(
|
||||
@@ -434,8 +684,28 @@ const baseProductionOrderSelect = `
|
||||
WHERE step.production_order_id = po.id
|
||||
),
|
||||
'[]'::json
|
||||
) as steps
|
||||
) as steps,
|
||||
COALESCE(
|
||||
(
|
||||
SELECT JSON_AGG(JSON_BUILD_OBJECT(
|
||||
'id', defect.id,
|
||||
'workstation_id', defect.workstation_id,
|
||||
'workstation_name', workstation.name,
|
||||
'defect_type', defect.defect_type,
|
||||
'origin', defect.origin,
|
||||
'quantity', defect.quantity,
|
||||
'lot_code', defect.lot_code,
|
||||
'notes', defect.notes,
|
||||
'occurred_at', defect.occurred_at
|
||||
) ORDER BY defect.occurred_at DESC, defect.id DESC)
|
||||
FROM production_order_defects defect
|
||||
LEFT JOIN production_workstations workstation ON workstation.id = defect.workstation_id
|
||||
WHERE defect.production_order_id = po.id
|
||||
),
|
||||
'[]'::json
|
||||
) as defects
|
||||
FROM production_orders po
|
||||
LEFT JOIN production_plans plan ON plan.id = po.production_plan_id
|
||||
`;
|
||||
|
||||
const listProductionOrders = async (filters = {}) => {
|
||||
@@ -482,7 +752,9 @@ const listProductionOrders = async (filters = {}) => {
|
||||
}, {
|
||||
all: 0,
|
||||
open: 0,
|
||||
in_progress: 0,
|
||||
cutting: 0,
|
||||
sewing: 0,
|
||||
review: 0,
|
||||
finished: 0,
|
||||
canceled: 0
|
||||
});
|
||||
@@ -490,19 +762,125 @@ const listProductionOrders = async (filters = {}) => {
|
||||
return { orders, counts };
|
||||
};
|
||||
|
||||
const createProductionOrders = async (orders = []) => {
|
||||
const listProductionPlans = async () => {
|
||||
const result = await pool.query(`
|
||||
SELECT
|
||||
plan.id, plan.number, plan.name, plan.status, plan.contractor, plan.responsible_user_id,
|
||||
responsible.name AS responsible_user_name, plan.responsible_name,
|
||||
plan.planned_start_date, plan.expected_date, plan.completed_at,
|
||||
plan.notes, plan.source, plan.created_at, plan.updated_at,
|
||||
COUNT(po.id)::int AS order_count,
|
||||
COALESCE(SUM(po.quantity), 0) AS total_quantity,
|
||||
COALESCE(SUM(po.actual_cut_quantity), 0) AS actual_cut_quantity,
|
||||
COALESCE(SUM(po.finished_quantity), 0) AS finished_quantity,
|
||||
COALESCE((
|
||||
SELECT JSON_OBJECT_AGG(status, count)
|
||||
FROM (
|
||||
SELECT status, COUNT(*)::int AS count
|
||||
FROM production_orders
|
||||
WHERE production_plan_id = plan.id
|
||||
GROUP BY status
|
||||
) grouped_statuses
|
||||
), '{}'::json) AS status_counts
|
||||
FROM production_plans plan
|
||||
LEFT JOIN production_orders po ON po.production_plan_id = plan.id
|
||||
LEFT JOIN app_users responsible ON responsible.id = plan.responsible_user_id
|
||||
GROUP BY plan.id, responsible.id
|
||||
ORDER BY COALESCE(plan.planned_start_date, plan.created_at::date) DESC, plan.id DESC;
|
||||
`);
|
||||
return result.rows.map(mapProductionPlanRow);
|
||||
};
|
||||
|
||||
const updateProductionPlan = async (id, payload = {}) => {
|
||||
const plan = normalizePlanPayload(payload);
|
||||
if (!plan.name) {
|
||||
const error = new Error('O nome do plano é obrigatório.');
|
||||
error.statusCode = 400;
|
||||
throw error;
|
||||
}
|
||||
const responsible = await resolvePlanResponsible(plan);
|
||||
const result = await pool.query(`
|
||||
UPDATE production_plans
|
||||
SET name = $1,
|
||||
contractor = NULLIF($2, ''),
|
||||
responsible_user_id = $3,
|
||||
responsible_name = NULLIF($4, ''),
|
||||
planned_start_date = $5,
|
||||
expected_date = $6,
|
||||
notes = NULLIF($7, ''),
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = $8
|
||||
RETURNING id;
|
||||
`, [plan.name, plan.contractor, responsible.userId, responsible.name, plan.plannedStartDate, plan.expectedDate, plan.notes, id]);
|
||||
if (!result.rowCount) {
|
||||
const error = new Error('Plano de produção não encontrado.');
|
||||
error.statusCode = 404;
|
||||
throw error;
|
||||
}
|
||||
return getProductionPlanById(id);
|
||||
};
|
||||
|
||||
const updateProductionPlanStatus = async (id, status) => {
|
||||
const normalizedStatus = normalizeStatus(status);
|
||||
if (!STATUS_LABELS[normalizedStatus]) {
|
||||
const error = new Error('Invalid production plan status');
|
||||
error.statusCode = 400;
|
||||
throw error;
|
||||
}
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
const planResult = await client.query(`
|
||||
UPDATE production_plans
|
||||
SET status = $1,
|
||||
completed_at = CASE WHEN $1 = 'finished' THEN COALESCE(completed_at, CURRENT_TIMESTAMP) ELSE NULL END,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = $2
|
||||
RETURNING id;
|
||||
`, [normalizedStatus, id]);
|
||||
if (!planResult.rowCount) {
|
||||
const error = new Error('Plano de produção não encontrado.');
|
||||
error.statusCode = 404;
|
||||
throw error;
|
||||
}
|
||||
await client.query(`
|
||||
UPDATE production_orders
|
||||
SET status = $1,
|
||||
completed_date = CASE WHEN $1 = 'finished' THEN COALESCE(completed_date, CURRENT_DATE) ELSE completed_date END,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE production_plan_id = $2
|
||||
AND status NOT IN ('finished', 'canceled');
|
||||
`, [normalizedStatus, id]);
|
||||
await client.query('COMMIT');
|
||||
return getProductionPlanById(id);
|
||||
} catch (error) {
|
||||
await client.query('ROLLBACK');
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
};
|
||||
|
||||
const createProductionOrders = async (orders = [], lotReservations = [], planPayload = null) => {
|
||||
const client = await pool.connect();
|
||||
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
const created = [];
|
||||
const skipped = [];
|
||||
const fallbackPlan = {
|
||||
name: normalizeText(orders[0]?.orderReference),
|
||||
plannedStartDate: orders[0]?.issueDate,
|
||||
expectedDate: orders[0]?.expectedDate
|
||||
};
|
||||
const plan = planPayload ? await createOrGetProductionPlan(client, planPayload, fallbackPlan) : null;
|
||||
|
||||
for (const order of orders) {
|
||||
const productDescription = normalizeText(order.productDescription);
|
||||
const productSku = normalizeText(order.productSku);
|
||||
const orderReference = normalizeText(order.orderReference);
|
||||
const quantity = normalizeQuantity(order.quantity);
|
||||
const components = normalizeComponents(order.components, quantity);
|
||||
|
||||
if (!productDescription || !quantity) {
|
||||
skipped.push({ productSku, productDescription, reason: 'invalid_payload' });
|
||||
@@ -514,7 +892,7 @@ const createProductionOrders = async (orders = []) => {
|
||||
FROM production_orders
|
||||
WHERE COALESCE(order_reference, '') = $1
|
||||
AND COALESCE(product_sku, '') = $2
|
||||
AND status IN ('open', 'in_progress')
|
||||
AND status IN ('open', 'in_progress', 'cutting', 'sewing', 'review')
|
||||
LIMIT 1;
|
||||
`, [orderReference, productSku]);
|
||||
|
||||
@@ -528,6 +906,8 @@ const createProductionOrders = async (orders = []) => {
|
||||
tiny_id,
|
||||
number,
|
||||
status,
|
||||
production_plan_id,
|
||||
contractor,
|
||||
order_reference,
|
||||
issue_date,
|
||||
expected_date,
|
||||
@@ -550,11 +930,15 @@ const createProductionOrders = async (orders = []) => {
|
||||
$7,
|
||||
$8,
|
||||
$9,
|
||||
$10::jsonb
|
||||
$10,
|
||||
$11,
|
||||
$12::jsonb
|
||||
)
|
||||
RETURNING id;
|
||||
`, [
|
||||
normalizeStatus(order.status || 'open'),
|
||||
plan?.id || null,
|
||||
normalizeText(order.contractor) || plan?.contractor || null,
|
||||
orderReference,
|
||||
normalizeDateParam(order.issueDate) || formatDate(new Date()),
|
||||
normalizeDateParam(order.expectedDate),
|
||||
@@ -584,12 +968,78 @@ const createProductionOrders = async (orders = []) => {
|
||||
`, [orderId, label, normalizeText(marker.color) || null]);
|
||||
}
|
||||
|
||||
for (const component of components) {
|
||||
await client.query(`
|
||||
INSERT INTO production_order_components (
|
||||
production_order_id, component_tiny_id, component_sku, component_name,
|
||||
quantity_per_unit, total_quantity, unit
|
||||
)
|
||||
VALUES ($1, NULLIF($2, ''), NULLIF($3, ''), $4, $5, $6, NULLIF($7, ''));
|
||||
`, [
|
||||
orderId,
|
||||
component.componentTinyId,
|
||||
component.componentSku,
|
||||
component.componentName,
|
||||
component.quantityPerUnit,
|
||||
component.totalQuantity,
|
||||
component.unit
|
||||
]);
|
||||
}
|
||||
|
||||
const createdOrder = await getOrderById(orderId, client);
|
||||
if (createdOrder) created.push(createdOrder);
|
||||
}
|
||||
|
||||
if (created.length && Array.isArray(lotReservations)) {
|
||||
for (const reservation of lotReservations) {
|
||||
const lotId = Number(reservation?.lotId);
|
||||
const quantity = normalizeQuantity(reservation?.quantity);
|
||||
const materialKey = normalizeText(reservation?.materialKey);
|
||||
const cutPlanReference = normalizeText(reservation?.cutPlanReference) || normalizeText(created[0]?.orderReference);
|
||||
if (!Number.isInteger(lotId) || !quantity || !materialKey || !cutPlanReference) {
|
||||
const error = new Error('Reserva de lote inválida.');
|
||||
error.statusCode = 400;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const lotResult = await client.query(`
|
||||
SELECT id, quantity, unit, status
|
||||
FROM supply_stock_lots
|
||||
WHERE id = $1
|
||||
FOR UPDATE;
|
||||
`, [lotId]);
|
||||
if (!lotResult.rowCount || lotResult.rows[0].status !== 'active') {
|
||||
const error = new Error('O lote selecionado não está disponível para reserva.');
|
||||
error.statusCode = 400;
|
||||
throw error;
|
||||
}
|
||||
const activeReservations = await client.query(`
|
||||
SELECT quantity, consumed_quantity
|
||||
FROM cut_plan_lot_reservations
|
||||
WHERE supply_lot_id = $1
|
||||
AND status = 'active'
|
||||
FOR UPDATE;
|
||||
`, [lotId]);
|
||||
const alreadyReserved = activeReservations.rows.reduce((total, row) => (
|
||||
total + Number(row.quantity || 0) - Number(row.consumed_quantity || 0)
|
||||
), 0);
|
||||
const availableQuantity = Math.max(0, Number(lotResult.rows[0].quantity || 0) - alreadyReserved);
|
||||
if (quantity > availableQuantity) {
|
||||
const error = new Error(`O lote #${lotId} possui apenas ${availableQuantity} ${lotResult.rows[0].unit} disponível para nova reserva.`);
|
||||
error.statusCode = 400;
|
||||
throw error;
|
||||
}
|
||||
await client.query(`
|
||||
INSERT INTO cut_plan_lot_reservations (
|
||||
cut_plan_reference, production_plan_id, supply_lot_id, material_key, quantity, consumed_quantity, status, updated_at
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, 0, 'active', CURRENT_TIMESTAMP);
|
||||
`, [cutPlanReference, plan?.id || null, lotId, materialKey, quantity]);
|
||||
}
|
||||
}
|
||||
|
||||
await client.query('COMMIT');
|
||||
return { created, skipped };
|
||||
return { created, skipped, plan };
|
||||
} catch (error) {
|
||||
await client.query('ROLLBACK');
|
||||
throw error;
|
||||
@@ -1304,7 +1754,13 @@ const updateProductionOrderStatus = async (id, status) => {
|
||||
|
||||
const result = await pool.query(`
|
||||
UPDATE production_orders
|
||||
SET status = $1, updated_at = CURRENT_TIMESTAMP
|
||||
SET status = $1::varchar,
|
||||
completed_date = CASE
|
||||
WHEN $1::text = 'finished' THEN COALESCE(completed_date, CURRENT_DATE)
|
||||
WHEN status = 'finished' THEN NULL
|
||||
ELSE completed_date
|
||||
END,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = $2
|
||||
RETURNING id;
|
||||
`, [normalizedStatus, id]);
|
||||
@@ -1318,16 +1774,110 @@ const updateProductionOrderStatus = async (id, status) => {
|
||||
return getOrderById(id);
|
||||
};
|
||||
|
||||
const updateProductionOrderExecution = async (id, payload = {}) => {
|
||||
const actualCutQuantity = normalizeNonNegativeQuantity(payload.actualCutQuantity);
|
||||
const rejectedQuantity = normalizeNonNegativeQuantity(payload.rejectedQuantity);
|
||||
const finishedQuantity = normalizeNonNegativeQuantity(payload.finishedQuantity);
|
||||
const qualityNotes = normalizeText(payload.qualityNotes);
|
||||
|
||||
if (actualCutQuantity === null && rejectedQuantity === null && finishedQuantity === null && !qualityNotes) {
|
||||
const error = new Error('Informe uma quantidade de execução ou uma observação de qualidade.');
|
||||
error.statusCode = 400;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const currentResult = await pool.query(`
|
||||
SELECT actual_cut_quantity, rejected_quantity, finished_quantity
|
||||
FROM production_orders
|
||||
WHERE id = $1;
|
||||
`, [id]);
|
||||
if (!currentResult.rowCount) {
|
||||
const error = new Error('Production order not found');
|
||||
error.statusCode = 404;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const current = currentResult.rows[0];
|
||||
const effectiveActual = actualCutQuantity === null ? (current.actual_cut_quantity === null ? null : Number(current.actual_cut_quantity)) : actualCutQuantity;
|
||||
const effectiveRejected = rejectedQuantity === null ? Number(current.rejected_quantity || 0) : rejectedQuantity;
|
||||
const effectiveFinished = finishedQuantity === null ? (current.finished_quantity === null ? null : Number(current.finished_quantity)) : finishedQuantity;
|
||||
|
||||
if (effectiveActual !== null && effectiveRejected > effectiveActual) {
|
||||
const error = new Error('As perdas não podem ser maiores que a quantidade cortada.');
|
||||
error.statusCode = 400;
|
||||
throw error;
|
||||
}
|
||||
if (effectiveActual !== null && effectiveFinished !== null && effectiveFinished > effectiveActual - effectiveRejected) {
|
||||
const error = new Error('A quantidade final não pode ser maior que o saldo após as perdas.');
|
||||
error.statusCode = 400;
|
||||
throw error;
|
||||
}
|
||||
|
||||
await pool.query(`
|
||||
UPDATE production_orders
|
||||
SET actual_cut_quantity = $1,
|
||||
rejected_quantity = $2,
|
||||
finished_quantity = $3,
|
||||
quality_notes = NULLIF($4, ''),
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = $5;
|
||||
`, [effectiveActual, effectiveRejected, effectiveFinished, qualityNotes, id]);
|
||||
|
||||
return getOrderById(id);
|
||||
};
|
||||
|
||||
const listProductionWorkstations = async () => {
|
||||
const result = await pool.query(`
|
||||
SELECT id, code, name, color, sort_order, is_active
|
||||
FROM production_workstations
|
||||
WHERE is_active = TRUE
|
||||
ORDER BY sort_order, name;
|
||||
`);
|
||||
return result.rows.map(row => ({ id: Number(row.id), code: row.code, name: row.name, color: row.color, sortOrder: Number(row.sort_order), isActive: Boolean(row.is_active) }));
|
||||
};
|
||||
|
||||
const addProductionOrderDefect = async (id, payload = {}) => {
|
||||
const defectType = normalizeText(payload.defectType);
|
||||
const quantity = normalizeQuantity(payload.quantity);
|
||||
const workstationId = normalizeInteger(payload.workstationId);
|
||||
if (!defectType || !quantity) {
|
||||
const error = new Error('Informe o tipo e a quantidade do defeito.');
|
||||
error.statusCode = 400;
|
||||
throw error;
|
||||
}
|
||||
if (workstationId) {
|
||||
const workstation = await pool.query('SELECT id FROM production_workstations WHERE id = $1 AND is_active = TRUE;', [workstationId]);
|
||||
if (!workstation.rowCount) {
|
||||
const error = new Error('Posto de trabalho inválido.');
|
||||
error.statusCode = 400;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
const result = await pool.query(`
|
||||
INSERT INTO production_order_defects (production_order_id, workstation_id, defect_type, origin, quantity, lot_code, notes)
|
||||
VALUES ($1, $2, $3, NULLIF($4, ''), $5, NULLIF($6, ''), NULLIF($7, ''))
|
||||
RETURNING id;
|
||||
`, [id, workstationId, defectType, normalizeText(payload.origin), quantity, normalizeText(payload.lotCode), normalizeText(payload.notes)]);
|
||||
if (!result.rowCount) throw new Error('Não foi possível registrar o defeito.');
|
||||
return getOrderById(id);
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
createProductionOrders,
|
||||
deleteManualProductComposition,
|
||||
getProductComposition,
|
||||
importTinyProductCompositionExport,
|
||||
listProductionPlans,
|
||||
listProductionWorkstations,
|
||||
listProductCompositions,
|
||||
listProductionOrders,
|
||||
normalizeStatus,
|
||||
saveManualProductComposition,
|
||||
upsertTinyProductComposition,
|
||||
upsertTinyProductionOrderDetail,
|
||||
updateProductionPlan,
|
||||
updateProductionPlanStatus,
|
||||
addProductionOrderDefect,
|
||||
updateProductionOrderExecution,
|
||||
updateProductionOrderStatus
|
||||
};
|
||||
|
||||
@@ -61,6 +61,8 @@ const mapLot = (row) => ({
|
||||
supplier: row.supplier || '',
|
||||
invoice: row.invoice || '',
|
||||
status: row.status,
|
||||
reservedQuantity: Number(row.reserved_quantity || 0),
|
||||
availableQuantity: Number(row.available_quantity === undefined ? row.quantity : row.available_quantity),
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at
|
||||
});
|
||||
@@ -99,6 +101,27 @@ const mapMaterialDetails = (row) => ({
|
||||
updatedAt: row.updated_at
|
||||
});
|
||||
|
||||
const PURCHASE_ORDER_STATUSES = new Set(['draft', '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 error = new Error(message);
|
||||
error.statusCode = 400;
|
||||
@@ -116,10 +139,19 @@ const listReceipts = async () => {
|
||||
|
||||
const listLots = async () => {
|
||||
const result = await pool.query(`
|
||||
SELECT id, receipt_id, category, product, quantity, unit, supplier, invoice, status, created_at, updated_at
|
||||
FROM supply_stock_lots
|
||||
WHERE status = 'active'
|
||||
ORDER BY created_at DESC, id DESC
|
||||
SELECT
|
||||
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,
|
||||
COALESCE(reservations.reserved_quantity, 0) AS reserved_quantity,
|
||||
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);
|
||||
};
|
||||
@@ -158,6 +190,83 @@ const listMaterialDetails = async () => {
|
||||
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_lots
|
||||
UNION ALL
|
||||
SELECT supplier FROM supply_receipts
|
||||
UNION ALL
|
||||
SELECT supplier FROM supply_fabric_plans
|
||||
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 normalizedKey = normalizeKey(materialKey);
|
||||
if (!normalizedKey) throw createValidationError('Material inválido para edição.');
|
||||
@@ -213,7 +322,7 @@ const buildPurchaseNeeds = (plans, lots, receipts) => {
|
||||
lots.forEach(lot => {
|
||||
if (lot.unit !== 'kg') return;
|
||||
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 => {
|
||||
@@ -604,7 +713,7 @@ const buildProjectPurchaseNeeds = async (lots, receipts) => {
|
||||
lots.forEach(lot => {
|
||||
const need = getNeedForInventory(lot.unit, '', 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 => {
|
||||
@@ -942,6 +1051,19 @@ const updateLotQuantity = async (client, lotId, quantity) => {
|
||||
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 countedQuantity = normalizeNonNegativeNumber(payload.countedQuantity);
|
||||
const reason = normalizeText(payload.reason);
|
||||
@@ -965,6 +1087,10 @@ const adjustInventoryLot = async (id, payload) => {
|
||||
|
||||
const lot = lotResult.rows[0];
|
||||
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 updatedLot = await updateLotQuantity(client, lot.id, countedQuantity);
|
||||
|
||||
@@ -1019,6 +1145,35 @@ const consumeLotForProduction = async (id, payload) => {
|
||||
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.');
|
||||
|
||||
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 movementReason = [
|
||||
productionOrderNumber ? `OP ${productionOrderNumber}` : '',
|
||||
@@ -1039,6 +1194,36 @@ const consumeLotForProduction = async (id, payload) => {
|
||||
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');
|
||||
return updatedLot;
|
||||
} catch (error) {
|
||||
@@ -1054,6 +1239,7 @@ module.exports = {
|
||||
approveReceipt,
|
||||
consumeLotForProduction,
|
||||
createFabricPlan,
|
||||
createPurchaseOrder,
|
||||
createReceipt,
|
||||
deleteFabricPlan,
|
||||
deleteReceipt,
|
||||
@@ -1062,9 +1248,12 @@ module.exports = {
|
||||
buildPurchaseNeeds,
|
||||
listFabricPlans,
|
||||
listMaterialDetails,
|
||||
listKnownSuppliers,
|
||||
listPurchaseOrders,
|
||||
listLots,
|
||||
listMovements,
|
||||
listPurchaseNeeds,
|
||||
listReceipts,
|
||||
saveMaterialDetails
|
||||
saveMaterialDetails,
|
||||
updatePurchaseOrderStatus
|
||||
};
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
const crypto = require('crypto');
|
||||
const { pool } = require('../db');
|
||||
|
||||
const SYSTEM_OPERATIONAL_USER_NAME = 'Super Admin';
|
||||
|
||||
const HASH_ALGORITHM = 'scrypt';
|
||||
const KEY_LENGTH = 64;
|
||||
|
||||
@@ -59,6 +61,39 @@ const listUsers = async () => {
|
||||
return result.rows.map(publicUserFields);
|
||||
};
|
||||
|
||||
// This deliberately exposes only the identity needed to assign operational
|
||||
// responsibility. It is available to signed-in Graphs users, unlike the full
|
||||
// user-management list which remains super-admin only.
|
||||
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 normalizedName = String(name || '').trim();
|
||||
const normalizedEmail = normalizeEmail(email);
|
||||
@@ -200,9 +235,12 @@ module.exports = {
|
||||
findUserById,
|
||||
findUserByEmail,
|
||||
generatePassword,
|
||||
getActiveOperationalUserById,
|
||||
listActiveOperationalUsers,
|
||||
listUsers,
|
||||
normalizeEmail,
|
||||
publicUserFields,
|
||||
SYSTEM_OPERATIONAL_USER_NAME,
|
||||
updateUser,
|
||||
verifyPassword
|
||||
};
|
||||
|
||||
@@ -12,6 +12,7 @@ const Replenishment = React.lazy(() => import('./pages/Replenishment'));
|
||||
const Cutting = React.lazy(() => import('./pages/Cutting'));
|
||||
const PlanningIssues = React.lazy(() => import('./pages/PlanningIssues'));
|
||||
const ProductionOrders = React.lazy(() => import('./pages/ProductionOrders'));
|
||||
const ProductionPlans = React.lazy(() => import('./pages/ProductionPlans'));
|
||||
const Supplies = React.lazy(() => import('./pages/Supplies'));
|
||||
const Clients = React.lazy(() => import('./pages/Clients'));
|
||||
const ClientDetails = React.lazy(() => import('./pages/ClientDetails'));
|
||||
@@ -64,6 +65,7 @@ function App() {
|
||||
<Route path="stock" element={<Navigate to="/products" replace />} />
|
||||
<Route path="stock-alerts" element={<Navigate to="/products" replace />} />
|
||||
<Route path="production-orders" element={<ProductionOrders />} />
|
||||
<Route path="production-plans" element={<ProductionPlans />} />
|
||||
<Route path="clients" element={<Clients />} />
|
||||
<Route path="clients/:clientToken" element={<ClientDetails />} />
|
||||
<Route path="sellers" element={<Sellers />} />
|
||||
|
||||
@@ -156,8 +156,11 @@ const normalizeMatchText = (value: string) => normalizeProductText(value)
|
||||
|
||||
const isOpenProductionOrder = (order: ProductionOrderItem) => (
|
||||
order.status === 'open' ||
|
||||
order.status === 'cutting' ||
|
||||
order.status === 'sewing' ||
|
||||
order.status === 'review' ||
|
||||
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 = (
|
||||
|
||||
125
src/components/StockGraphsBalanceModal.tsx
Normal file
125
src/components/StockGraphsBalanceModal.tsx
Normal 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;
|
||||
@@ -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, GraphsStockBalanceHistoryItem, GraphsStockBalancePayload, ManagedUser, OrderData, OlistRunDetails, OlistStatus, ProductAnalyticsItem, ProductComposition, ProductCompositionImportSummary, ProductCompositionPayload, 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, OperationalUser, OrderData, OlistRunDetails, OlistStatus, ProductAnalyticsItem, ProductComposition, ProductCompositionImportSummary, ProductCompositionPayload, ProductDetailsAnalytics, ProductionOrderExecutionPayload, ProductionOrderImportData, ProductionOrderItem, ProductionOrderPayload, ProductionOrderStatus, ProductionOrderSummary, ProductionPlan, ProductionPlanPayload, RfmAnalytics, SellerAnalyticsItem, SellerDetailsAnalytics, StockData, SupplyFabricPlan, SupplyFabricPlanPayload, SupplyInventoryAdjustmentPayload, SupplyLot, SupplyMaterialDetailsPayload, SupplyProductionExitPayload, SupplyPurchaseNeed, SupplyPurchaseOrder, SupplyPurchaseOrderPayload, SupplyPurchaseOrderStatus, SupplyReceipt, SupplyReceiptPayload, SupplySummary } from './types';
|
||||
import { formatDateParam } from './dateRanges';
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL || '/api';
|
||||
@@ -230,20 +230,20 @@ export const fetchProductionOrders = async (
|
||||
return getCachedAnalytics(path, async () => {
|
||||
try {
|
||||
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();
|
||||
} catch (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);
|
||||
};
|
||||
|
||||
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', {
|
||||
method: 'POST',
|
||||
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.');
|
||||
@@ -251,6 +251,36 @@ export const createProductionOrders = async (orders: ProductionOrderPayload[]):
|
||||
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 updateProductionOrderStatus = async (id: number, status: ProductionOrderStatus): Promise<ProductionOrderItem> => {
|
||||
const response = await authFetch(`/production-orders/${id}/status`, {
|
||||
method: 'PATCH',
|
||||
@@ -263,6 +293,19 @@ export const updateProductionOrderStatus = async (id: number, status: Production
|
||||
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 fetchCuttingSettings = async (): Promise<CuttingSettings> => {
|
||||
try {
|
||||
const response = await authFetch('/cutting-settings');
|
||||
@@ -525,6 +568,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 (
|
||||
materialKey: string,
|
||||
payload: SupplyMaterialDetailsPayload
|
||||
@@ -993,6 +1070,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> => {
|
||||
const response = await authFetch('/users', {
|
||||
method: 'POST',
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
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 PaginationControls from '../components/PaginationControls';
|
||||
import ProductColorBadge from '../components/ProductColorBadge';
|
||||
import RefreshStatus from '../components/RefreshStatus';
|
||||
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 type { CuttingSettings, DateRange, ProductAnalyticsItem, ProductComposition, ProductionOrderItem, ProductionYieldRecommendation, StockData } from '../types';
|
||||
import { createProductionOrders, exportToCSV, fetchCuttingSettings, fetchOperationalUsers, fetchProductAnalytics, fetchProductCompositions, fetchProductionOrders, fetchProductionOrderImportData, fetchStock, fetchSupplySummary, saveCuttingSettings } from '../dataService';
|
||||
import type { CuttingSettings, DateRange, OperationalUser, ProductAnalyticsItem, ProductComposition, ProductionOrderItem, ProductionYieldRecommendation, StockData, SupplyLot } from '../types';
|
||||
import { getPlanningStock } from '../planningStock';
|
||||
|
||||
type CutFilter = 'need' | 'all' | 'issues' | 'covered';
|
||||
@@ -18,7 +18,9 @@ type CutProductIssue = Exclude<CutIssue, 'missing_yield_rule'>;
|
||||
type CorrectionIssueFilter = CutProductIssue | 'all';
|
||||
type SettingsSection = 'rules' | 'corrections';
|
||||
type CuttingView = 'plan' | 'families' | 'issues';
|
||||
type PreviewStep = 'selection' | 'confirmation';
|
||||
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 coverageTargetOptions = [7, 15, 30, 60];
|
||||
@@ -142,6 +144,7 @@ const Cutting = () => {
|
||||
const [productionOrders, setProductionOrders] = useState<ProductionOrderItem[]>([]);
|
||||
const [compositions, setCompositions] = useState<ProductComposition[]>([]);
|
||||
const [materialStock, setMaterialStock] = useState<StockData[]>([]);
|
||||
const [supplyLots, setSupplyLots] = useState<SupplyLot[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [targetCoverageDays, setTargetCoverageDays] = useState(30);
|
||||
@@ -161,9 +164,16 @@ const Cutting = () => {
|
||||
const [itemsPerPage, setItemsPerPage] = useState(10);
|
||||
const [isGeneratingOrders, setIsGeneratingOrders] = useState(false);
|
||||
const [isOrderPreviewOpen, setIsOrderPreviewOpen] = useState(false);
|
||||
const [previewStep, setPreviewStep] = useState<PreviewStep>('selection');
|
||||
const [previewPage, setPreviewPage] = useState(1);
|
||||
const [previewItemsPerPage, setPreviewItemsPerPage] = useState(10);
|
||||
const [selectedPreviewRowIds, setSelectedPreviewRowIds] = useState<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 [quickCutEdit, setQuickCutEdit] = useState<CutProductOverride>({});
|
||||
const [isQuickCutEditSaving, setIsQuickCutEditSaving] = useState(false);
|
||||
@@ -213,17 +223,19 @@ const Cutting = () => {
|
||||
|
||||
const loadProducts = async () => {
|
||||
setIsLoading(true);
|
||||
const [productData, productionOrderData, compositionData, stockData] = await Promise.all([
|
||||
const [productData, productionOrderData, compositionData, stockData, supplySummary] = await Promise.all([
|
||||
fetchProductAnalytics(dateRange),
|
||||
fetchProductionOrders(allProductionOrdersRange),
|
||||
fetchProductCompositions(),
|
||||
fetchStock()
|
||||
fetchStock(),
|
||||
fetchSupplySummary()
|
||||
]);
|
||||
if (isMounted) {
|
||||
setProducts(productData);
|
||||
setProductionOrders(productionOrderData.orders);
|
||||
setCompositions(compositionData);
|
||||
setMaterialStock(stockData);
|
||||
setSupplyLots(supplySummary.lots);
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
@@ -245,7 +257,7 @@ const Cutting = () => {
|
||||
[cuttingSettings, dateRange, openProductionByProductId, products, targetCoverageDays]
|
||||
);
|
||||
|
||||
const materialReadinessByProductId = useMemo(() => {
|
||||
const compositionsByProductId = useMemo(() => {
|
||||
const compositionsById = new Map<string, ProductComposition>();
|
||||
compositions.forEach(composition => {
|
||||
[composition.finishedTinyProductId, composition.finishedProductSku]
|
||||
@@ -253,6 +265,10 @@ const Cutting = () => {
|
||||
.filter(Boolean)
|
||||
.forEach(key => compositionsById.set(key, composition));
|
||||
});
|
||||
return compositionsById;
|
||||
}, [compositions]);
|
||||
|
||||
const materialReadinessByProductId = useMemo(() => {
|
||||
const stockIndexByKey = new Map<string, number>();
|
||||
materialStock.forEach((stock, index) => {
|
||||
[stock.produto_id, stock.nome].map(normalizeMaterialKey).filter(Boolean).forEach(key => stockIndexByKey.set(key, index));
|
||||
@@ -262,7 +278,7 @@ const Cutting = () => {
|
||||
// Shared stock is allocated to the largest current requirement first. This
|
||||
// prevents every SKU from claiming the same material balance independently.
|
||||
[...cutPlan.rows].sort((a, b) => b.suggestedCutQuantity - a.suggestedCutQuantity || a.name.localeCompare(b.name, 'pt-BR')).forEach(row => {
|
||||
const composition = compositionsById.get(normalizeMaterialKey(row.id));
|
||||
const composition = compositionsByProductId.get(normalizeMaterialKey(row.id));
|
||||
if (!composition?.components.length) {
|
||||
// Products without an imported composition remain eligible for cutting.
|
||||
// They are excluded from material-demand calculations instead of
|
||||
@@ -299,7 +315,7 @@ const Cutting = () => {
|
||||
readinessByProductId.set(row.id, { status: 'ready', blockers: [] });
|
||||
});
|
||||
return readinessByProductId;
|
||||
}, [compositions, cutPlan.rows, materialStock]);
|
||||
}, [compositionsByProductId, cutPlan.rows, materialStock]);
|
||||
|
||||
const issueSummaries = useMemo(() => {
|
||||
const counts = new Map<CutIssue, number>();
|
||||
@@ -382,6 +398,46 @@ const Cutting = () => {
|
||||
const orderPreviewTotalUnits = useMemo(() => (
|
||||
selectedPreviewRows.reduce((total, row) => total + row.suggestedCutQuantity, 0)
|
||||
), [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 += row.suggestedCutQuantity * component.quantityPerUnit;
|
||||
requirements.set(key, current);
|
||||
});
|
||||
});
|
||||
return Array.from(requirements.values()).sort((a, b) => a.label.localeCompare(b.label, 'pt-BR'));
|
||||
}, [compositionsByProductId, 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(() => (
|
||||
selectedPreviewRows.filter(row => row.issues.length).length
|
||||
@@ -619,6 +675,13 @@ const Cutting = () => {
|
||||
setGenerationMessage('');
|
||||
setPreviewPage(1);
|
||||
setSelectedPreviewRowIds(rowsAvailableForOrder.map(row => row.id));
|
||||
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);
|
||||
};
|
||||
|
||||
@@ -657,7 +720,7 @@ const Cutting = () => {
|
||||
setGenerationMessage('');
|
||||
|
||||
const issueDate = formatDateKey(new Date());
|
||||
const orderReference = `Plano de corte ${formatDateKey(dateRange.start)} a ${formatDateKey(dateRange.end)} · ${targetCoverageDays} dias`;
|
||||
const orderReference = cutPlanReference;
|
||||
|
||||
try {
|
||||
const result = await createProductionOrders(selectedPreviewRows.map(row => ({
|
||||
@@ -669,6 +732,16 @@ const Cutting = () => {
|
||||
quantity: row.suggestedCutQuantity,
|
||||
unit: 'UN',
|
||||
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: row.suggestedCutQuantity * component.quantityPerUnit,
|
||||
unit: component.unit
|
||||
})),
|
||||
markers: [
|
||||
{ label: row.family.materialLabel, color: '#38bdf8' },
|
||||
...(row.color ? [{ label: row.color, color: '#52DFA0' }] : []),
|
||||
@@ -689,11 +762,19 @@ const Cutting = () => {
|
||||
availableQuantity: row.availableQuantity,
|
||||
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 });
|
||||
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);
|
||||
} catch (error) {
|
||||
setGenerationMessage(error instanceof Error ? error.message : 'Não foi possível gerar as ordens de produção.');
|
||||
@@ -817,9 +898,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>
|
||||
<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">
|
||||
Escolha os SKUs para criar ordens locais das 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>
|
||||
</div>
|
||||
<button
|
||||
@@ -834,20 +917,103 @@ const Cutting = () => {
|
||||
</button>
|
||||
</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">
|
||||
{[
|
||||
['Selecionadas', `${formatNumber(selectedPreviewRows.length)} de ${formatNumber(rowsAvailableForOrder.length)}`, 'text-dark-text'],
|
||||
['Unidades selecionadas', `${formatNumber(orderPreviewTotalUnits)} un.`, 'text-brand-primary'],
|
||||
['Cobertura alvo', `${formatNumber(targetCoverageDays)} dias`, 'text-dark-text'],
|
||||
['Revisar dados', formatNumber(previewRowsWithIssues), previewRowsWithIssues ? 'text-amber-300' : 'text-emerald-300']
|
||||
].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>)}
|
||||
<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'}`}>
|
||||
<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>
|
||||
Selecionar OPs
|
||||
</span>
|
||||
<ArrowRight className="h-3.5 w-3.5 text-dark-muted" />
|
||||
<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">
|
||||
{selectedPreviewRows.map(row => (
|
||||
<div key={row.id} className="flex items-center justify-between gap-4 px-3 py-2.5">
|
||||
<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>
|
||||
<span className="shrink-0 text-sm font-bold text-brand-primary">{formatNumber(row.suggestedCutQuantity)} un.</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</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">Todos os SKUs abaixo estão aptos para gerar OP. A seleção permanece ao trocar de página.</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button type="button" onClick={() => setPreviewSelection(rowsAvailableForOrder)} className="rounded-lg border border-dark-border bg-dark-card px-3 py-1.5 text-xs font-bold text-dark-text transition-colors hover:border-brand-primary cursor-pointer">{areAllPreviewRowsSelected ? 'Todos selecionados' : 'Selecionar todos'}</button>
|
||||
<button type="button" onClick={() => setPreviewSelection(rowsAvailableForOrder.filter(row => !row.issues.length))} className="rounded-lg border border-emerald-400/30 bg-emerald-400/10 px-3 py-1.5 text-xs font-bold text-emerald-300 transition-colors hover:border-emerald-400/60 cursor-pointer">Somente dados completos</button>
|
||||
<button type="button" onClick={() => setPreviewSelection([])} disabled={!selectedPreviewRows.length} className="rounded-lg border border-dark-border bg-dark-card px-3 py-1.5 text-xs font-bold text-dark-muted transition-colors hover:border-red-400/40 hover:text-red-300 disabled:cursor-not-allowed disabled:opacity-50 cursor-pointer">Limpar</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -897,8 +1063,20 @@ const Cutting = () => {
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</>}
|
||||
|
||||
<PaginationControls
|
||||
{previewStep === 'confirmation' && selectedMaterialRequirements.length > 0 && <section className="border-t border-dark-border bg-dark-input/30 p-4">
|
||||
<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={rowsAvailableForOrder.length}
|
||||
currentPage={safePreviewPage}
|
||||
totalPages={previewTotalPages}
|
||||
@@ -911,29 +1089,32 @@ const Cutting = () => {
|
||||
onPageChange={setPreviewPage}
|
||||
onPageSizeChange={(pageSize) => { setPreviewItemsPerPage(pageSize); setPreviewPage(1); }}
|
||||
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">
|
||||
<p className="text-xs font-semibold text-dark-muted">
|
||||
OPs abertas já existentes para o mesmo plano e SKU serão ignoradas.
|
||||
<p className="text-xs font-semibold text-dark-muted">{previewStep === 'selection'
|
||||
? '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>
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsOrderPreviewOpen(false)}
|
||||
onClick={() => previewStep === 'selection' ? setIsOrderPreviewOpen(false) : setPreviewStep('selection')}
|
||||
disabled={isGeneratingOrders}
|
||||
className="inline-flex items-center justify-center rounded-xl border border-dark-border bg-dark-input px-4 py-2 text-sm font-bold text-dark-text transition-colors hover:border-brand-primary disabled:cursor-not-allowed disabled:opacity-50 cursor-pointer"
|
||||
>
|
||||
Cancelar
|
||||
{previewStep === 'selection' ? 'Cancelar' : 'Voltar'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void generateProductionOrders()}
|
||||
onClick={() => previewStep === 'selection' ? setPreviewStep('confirmation') : void generateProductionOrders()}
|
||||
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"
|
||||
>
|
||||
<ClipboardList className="h-4 w-4" />
|
||||
{isGeneratingOrders ? 'Gerando OPs' : `Confirmar ${formatNumber(selectedPreviewRows.length)} ${selectedPreviewRows.length === 1 ? 'OP' : 'OPs'}`}
|
||||
{previewStep === 'selection' ? <ArrowRight className="h-4 w-4" /> : <ClipboardList className="h-4 w-4" />}
|
||||
{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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,30 +1,34 @@
|
||||
import { Fragment, type FormEvent, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Link, useNavigate, useOutletContext } from 'react-router-dom';
|
||||
import { ArrowLeft, CheckCircle2, ChevronDown, ChevronUp, ClipboardList, Clock3, Download, PackageCheck, PlayCircle, Search, Undo2, XCircle } from 'lucide-react';
|
||||
import { ArrowLeft, CheckCircle2, ChevronDown, ChevronUp, ClipboardList, Clock3, Download, PackageCheck, PlayCircle, Printer, Scissors, Search, Undo2, XCircle } from 'lucide-react';
|
||||
import DateRangePicker from '../components/DateRangePicker';
|
||||
import PaginationControls from '../components/PaginationControls';
|
||||
import RefreshStatus from '../components/RefreshStatus';
|
||||
import { consumeSupplyLotForProduction, exportToCSV, fetchProductionOrders, fetchSupplySummary, updateProductionOrderStatus } from '../dataService';
|
||||
import { consumeSupplyLotForProduction, exportToCSV, fetchProductionOrders, fetchSupplySummary, updateProductionOrderExecution, updateProductionOrderStatus } from '../dataService';
|
||||
import type { DateRange, ProductionOrderItem, ProductionOrderStatus, ProductionOrderSummary, SupplyLot } from '../types';
|
||||
|
||||
type ProductionOrderStatusTab = 'all' | 'open' | 'in_progress' | 'finished' | 'canceled';
|
||||
type ProductionOrderStatusTab = 'all' | 'open' | 'cutting' | 'sewing' | 'review' | 'finished' | 'canceled';
|
||||
|
||||
const emptySummary: ProductionOrderSummary = {
|
||||
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 }> = [
|
||||
{ key: 'all', label: 'Todas', dotClass: 'bg-dark-muted' },
|
||||
{ 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: 'canceled', label: 'Cancelada', dotClass: 'bg-zinc-500' }
|
||||
];
|
||||
|
||||
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: '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: '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',
|
||||
dotClass: 'bg-amber-400'
|
||||
},
|
||||
in_progress: {
|
||||
label: 'Em andamento',
|
||||
cutting: {
|
||||
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',
|
||||
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: {
|
||||
label: 'Finalizada',
|
||||
className: 'border-emerald-400/35 bg-emerald-400/10 text-emerald-300',
|
||||
@@ -128,7 +142,9 @@ const ProductionOrders = () => {
|
||||
const [supplyLots, setSupplyLots] = useState<SupplyLot[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isSupplyBusy, setIsSupplyBusy] = useState(false);
|
||||
const [busyExecutionOrderId, setBusyExecutionOrderId] = useState<number | null>(null);
|
||||
const [supplyMessage, setSupplyMessage] = useState('');
|
||||
const [executionMessage, setExecutionMessage] = useState('');
|
||||
const [busyStatusOrderId, setBusyStatusOrderId] = useState<number | null>(null);
|
||||
const [statusMessage, setStatusMessage] = useState('');
|
||||
const [selectedOrderId, setSelectedOrderId] = useState<number | null>(null);
|
||||
@@ -193,7 +209,7 @@ const ProductionOrders = () => {
|
||||
const isRefreshing = isLoading && summary.orders.length > 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 totalQuantity = summary.orders.reduce((total, order) => total + order.quantity, 0);
|
||||
const selectedOrder = summary.orders.find(order => order.id === selectedOrderId) || null;
|
||||
@@ -270,6 +286,46 @@ 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(),
|
||||
});
|
||||
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 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 exportData = filteredOrders.map(order => ({
|
||||
'Numero': order.number,
|
||||
@@ -302,7 +358,7 @@ const ProductionOrders = () => {
|
||||
|
||||
<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="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">Previsão</p><p className="mt-1 text-sm font-bold text-dark-text">{formatDate(order.expectedDate)}</p></div>
|
||||
</div>
|
||||
@@ -316,14 +372,39 @@ const ProductionOrders = () => {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<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 finalizar.</p>
|
||||
</div>
|
||||
<button type="button" onClick={() => printProductionSheet(order)} className="inline-flex h-9 items-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 hover:text-brand-primary cursor-pointer"><Printer className="h-4 w-4" />Imprimir ficha</button>
|
||||
</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-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>
|
||||
|
||||
<section>
|
||||
<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>}
|
||||
</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>}
|
||||
|
||||
<section>
|
||||
<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>
|
||||
|
||||
{(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>}
|
||||
@@ -341,6 +422,7 @@ const ProductionOrders = () => {
|
||||
</Link>
|
||||
<h1 className="text-2xl font-bold text-dark-text">Ordens de Produção</h1>
|
||||
<p className="mt-2 text-dark-muted font-medium">Acompanhe as ordens locais geradas pelo plano de corte.</p>
|
||||
<Link to="/production-plans" className="mt-3 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>
|
||||
</div>
|
||||
<DateRangePicker
|
||||
dateRange={dateRange}
|
||||
@@ -527,6 +609,7 @@ const ProductionOrders = () => {
|
||||
<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>{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>}
|
||||
</div>
|
||||
</td>
|
||||
|
||||
140
src/pages/ProductionPlans.tsx
Normal file
140
src/pages/ProductionPlans.tsx
Normal file
@@ -0,0 +1,140 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { ArrowLeft, Check, CheckCircle2, ChevronDown, ClipboardList, Factory, Loader2, PlayCircle, UserRound, type LucideIcon } from 'lucide-react';
|
||||
import { fetchOperationalUsers, fetchProductionPlans, updateProductionPlan, updateProductionPlanStatus } from '../dataService';
|
||||
import type { OperationalUser, ProductionOrderStatus, ProductionPlan } 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' }
|
||||
];
|
||||
|
||||
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 [busyPlanId, setBusyPlanId] = useState<number | null>(null);
|
||||
const [operationalUsers, setOperationalUsers] = useState<OperationalUser[]>([]);
|
||||
const [responsiblePickerPlanId, setResponsiblePickerPlanId] = useState<number | null>(null);
|
||||
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 () => {
|
||||
setOperationalUsers(await fetchOperationalUsers());
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setTimeout(() => { void Promise.all([loadPlans(), loadOperationalUsers()]); }, 0);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [loadOperationalUsers, loadPlans]);
|
||||
|
||||
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 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);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div>
|
||||
<Link to="/production-orders" 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" />Ordens de Produção</Link>
|
||||
<h1 className="text-2xl font-bold text-zinc-900 dark:text-dark-text">Planos de Produção</h1>
|
||||
<p className="mt-2 font-medium text-zinc-500 dark:text-dark-muted">Controle PCP acima das fichas: cada plano agrupa OPs, materiais reservados e a movimentação da operação.</p>
|
||||
</div>
|
||||
<button type="button" onClick={() => void loadPlans()} className="inline-flex h-10 items-center justify-center 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">Atualizar</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;
|
||||
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><MetricIcon className={`h-5 w-5 ${metric.tone}`} /></div><p className="mt-3 text-3xl font-bold text-dark-text">{value}</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="overflow-hidden rounded-2xl border border-dark-border bg-dark-card shadow-sm">
|
||||
<div className="border-b border-dark-border px-5 py-4"><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 todas as OPs locais que continuam ativas; OPs concluídas e canceladas preservam seu histórico.</p></div>
|
||||
{isLoading ? <div className="flex min-h-56 items-center justify-center text-brand-primary"><Loader2 className="h-7 w-7 animate-spin" /></div> : plans.length ? (
|
||||
<div className="divide-y divide-dark-border">
|
||||
{plans.map(plan => (
|
||||
<div key={plan.id} className="grid gap-4 p-5 xl:grid-cols-[minmax(240px,1.3fr)_minmax(150px,.7fr)_minmax(160px,.7fr)_minmax(190px,.75fr)_180px] xl:items-center">
|
||||
<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></div><p className="mt-2 truncate font-bold text-dark-text" title={plan.name}>{plan.name}</p><p className="mt-1 text-xs font-semibold text-dark-muted">{plan.responsibleName ? `Responsável: ${plan.responsibleName}` : plan.contractor ? `Responsável: ${plan.contractor}` : 'Sem responsável definido'} · previsão {formatDate(plan.expectedDate)}</p>{plan.notes && <p className="mt-1 truncate text-xs font-semibold text-dark-muted" title={plan.notes}>{plan.notes}</p>}</div>
|
||||
<div className="text-sm"><p className="text-xs font-bold uppercase tracking-wider text-dark-muted">Escopo</p><p className="mt-1 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">Cortado {formatNumber(plan.actualCutQuantity)} · final {formatNumber(plan.finishedQuantity)}</p></div>
|
||||
<div className="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-1 text-xs font-bold ${statusStyle(option.value)}`}>{plan.statusCounts[option.value]} {option.label.toLowerCase()}</span>)}</div>
|
||||
<div className="relative text-xs font-bold text-dark-muted"><p>Responsável</p><button type="button" disabled={busyPlanId === plan.id} onClick={() => setResponsiblePickerPlanId(current => current === plan.id ? null : plan.id)} className="mt-1 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 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 right-0 z-20 mt-2 w-full min-w-[270px] 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">{operationalUsers.map(user => <button key={user.id ?? 'super-admin'} type="button" 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 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>)}{!operationalUsers.length && <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" 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-dark-text cursor-pointer">Remover responsável</button>}</div>}</div>
|
||||
<label className="text-xs font-bold text-dark-muted">Mover plano<select value={plan.status} disabled={busyPlanId === plan.id} onChange={(event) => void movePlan(plan, event.target.value)} className="mt-1 h-10 w-full rounded-lg border border-dark-border bg-dark-input px-3 text-sm font-bold 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 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">Nenhum plano confirmado</h3><p className="mt-1 text-sm font-semibold text-dark-muted">Gere OPs a partir do Plano de Corte para criar o primeiro plano PCP.</p></div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProductionPlans;
|
||||
@@ -29,12 +29,13 @@ import { buildSkuEditPath } from '../catalogLinks';
|
||||
import DateRangePicker from '../components/DateRangePicker';
|
||||
import PaginationControls from '../components/PaginationControls';
|
||||
import ProductTypeBadge from '../components/ProductTypeBadge';
|
||||
import StockGraphsBalanceModal from '../components/StockGraphsBalanceModal';
|
||||
import { classifyCutFamily } from '../analytics/cutting';
|
||||
import { adjustSupplyLotInventory, approveSupplyReceipt, consumeSupplyLotForProduction, createSupplyFabricPlan, createSupplyReceipt, deleteSupplyFabricPlan, deleteSupplyReceipt, downloadDatabaseDiagnostic, fetchCuttingSettings, fetchProductAnalytics, fetchProductionOrders, fetchStock, fetchSupplySummary, isSuperAdmin, saveSupplyMaterialDetails } from '../dataService';
|
||||
import { adjustSupplyLotInventory, approveSupplyReceipt, consumeSupplyLotForProduction, createSupplyFabricPlan, createSupplyPurchaseOrder, createSupplyReceipt, deleteSupplyFabricPlan, deleteSupplyReceipt, downloadDatabaseDiagnostic, fetchCuttingSettings, fetchProductAnalytics, fetchProductionOrders, fetchStock, fetchSupplyPurchaseOrders, fetchSupplySummary, fetchSupplySuppliers, isSuperAdmin, saveSupplyMaterialDetails, updateSupplyPurchaseOrderStatus } from '../dataService';
|
||||
import { parseProductName } from '../productParsing';
|
||||
import { resolveProductType, type ProductTypeKey } from '../productClassification';
|
||||
import { getPlanningStock } from '../planningStock';
|
||||
import type { CuttingSettings, DateRange, ProductAnalyticsItem, StockData, SupplyFabricPlan, SupplyLot, SupplyMovement, SupplyPurchaseNeed, SupplyReceipt, SupplySummary } from '../types';
|
||||
import type { CuttingSettings, DateRange, ProductAnalyticsItem, StockData, SupplyFabricPlan, SupplyLot, SupplyMovement, SupplyPurchaseNeed, SupplyPurchaseOrder, SupplyPurchaseOrderStatus, SupplyReceipt, SupplySummary } from '../types';
|
||||
|
||||
type InventoryTab = 'dashboard' | 'stock' | 'balance' | 'receipts' | 'inventory' | 'movements';
|
||||
type ReceiptView = 'new' | 'pending' | 'history';
|
||||
@@ -86,6 +87,7 @@ const formatDateTime = (value: string | null) => {
|
||||
};
|
||||
|
||||
const normalizeSearch = (value: string) => value.normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase();
|
||||
const normalizeMaterialKey = (value: string) => normalizeSearch(value).replace(/\s+/g, ' ').trim();
|
||||
|
||||
const exportCsv = (filename: string, rows: Array<Record<string, string | number | null>>) => {
|
||||
if (!rows.length) return;
|
||||
@@ -289,7 +291,7 @@ const SuppliesHub = () => {
|
||||
]);
|
||||
if (!isMounted) return;
|
||||
setSummary(supplySummary);
|
||||
setProductionCounts({ open: productionSummary.counts.open || 0, inProgress: productionSummary.counts.in_progress || 0 });
|
||||
setProductionCounts({ open: productionSummary.counts.open || 0, inProgress: (productionSummary.counts.cutting || 0) + (productionSummary.counts.sewing || 0) + (productionSummary.counts.review || 0) });
|
||||
};
|
||||
void loadHub();
|
||||
return () => { isMounted = false; };
|
||||
@@ -409,6 +411,7 @@ const SuppliesHub = () => {
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2 xl:grid-cols-4">
|
||||
<ModuleCard title="Estoque e recebimentos" description="Saldo, lotes, recebimentos, inventário e movimentações de materiais." icon={Package} to="/supplies/inventory" meta={`${formatNumber(summary.stats.pendingReceipts, 0)} recebimentos pendentes · ${formatNumber(summary.stats.activeLots, 0)} lotes ativos`} />
|
||||
<ModuleCard title="Ordens de compra" description="Formalize pedidos de materiais, fornecedor, prazo e valor antes do recebimento." icon={ClipboardList} to="/supplies/purchase-orders" meta="Criar e acompanhar OCs" />
|
||||
<ModuleCard title="Planejamento de malha" description="Organize a fila de matéria-prima para abastecer compras e corte." icon={Ruler} to="/supplies/fabric-planning" />
|
||||
<ModuleCard title="Prioridades de estoque" description="Analise reposição, cobertura e itens que precisam de revisão de cadastro." icon={PackageSearch} to="/supplies/current-planning" />
|
||||
<ModuleCard title="Dados para revisar" description="Consulte exceções de estoque, composição e cadastro fora do cálculo atual." icon={ClipboardList} to="/planning-issues" meta="Abrir pendências" />
|
||||
@@ -2314,12 +2317,18 @@ const getMaterialRegistrationPath = (material: string) => buildSkuEditPath({
|
||||
|
||||
const MaterialDetailsModal = ({
|
||||
need,
|
||||
linkedStock,
|
||||
supplierOptions,
|
||||
onClose,
|
||||
onSave,
|
||||
onEditGraphsBalance,
|
||||
}: {
|
||||
need: SupplyPurchaseNeed;
|
||||
linkedStock: StockData | null;
|
||||
supplierOptions: string[];
|
||||
onClose: () => void;
|
||||
onSave: (payload: { displayName: string; preferredSupplier: string; notes: string }) => Promise<void>;
|
||||
onEditGraphsBalance: (stock: StockData) => void;
|
||||
}) => {
|
||||
const [form, setForm] = useState({
|
||||
displayName: need.material,
|
||||
@@ -2363,8 +2372,19 @@ const MaterialDetailsModal = ({
|
||||
</label>
|
||||
<label className="block text-xs font-bold text-dark-muted">
|
||||
Fornecedor preferencial
|
||||
<input value={form.preferredSupplier} onChange={(event) => setForm(current => ({ ...current, preferredSupplier: event.target.value }))} placeholder="Opcional" className={`${inputClassName} mt-1`} />
|
||||
<input list="known-suppliers" value={form.preferredSupplier} onChange={(event) => setForm(current => ({ ...current, preferredSupplier: event.target.value }))} placeholder="Selecione ou informe um fornecedor" className={`${inputClassName} mt-1`} />
|
||||
<datalist id="known-suppliers">{supplierOptions.map(supplier => <option key={supplier} value={supplier} />)}</datalist>
|
||||
<span className="mt-1 block text-[11px] font-semibold text-dark-muted">Sugestões carregadas do histórico de fornecedores e recebimentos.</span>
|
||||
</label>
|
||||
<div className="rounded-xl border border-dark-border bg-dark-input/50 p-3">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<p className="text-xs font-bold uppercase tracking-widest text-dark-muted">Saldo físico no Graphs</p>
|
||||
{linkedStock ? <><p className="mt-1 text-sm font-bold text-brand-primary">{linkedStock.graphs_balance === null || linkedStock.graphs_balance === undefined ? 'Não informado' : `${new Intl.NumberFormat('pt-BR', { maximumFractionDigits: 2 }).format(Number(linkedStock.graphs_balance))} un.`}</p><p className="mt-1 text-[11px] font-semibold text-dark-muted">Tiny: {new Intl.NumberFormat('pt-BR', { maximumFractionDigits: 2 }).format(Number(linkedStock.saldo))} un. · usado no plano: {new Intl.NumberFormat('pt-BR', { maximumFractionDigits: 2 }).format(Math.max(0, Number(linkedStock.planning_balance ?? linkedStock.saldo)))} un.</p></> : <p className="mt-1 text-xs font-semibold text-dark-muted">Este material ainda não possui saldo importado do Tiny para conferência.</p>}
|
||||
</div>
|
||||
{linkedStock && <button type="button" onClick={() => onEditGraphsBalance(linkedStock)} className="inline-flex h-9 shrink-0 items-center justify-center gap-2 rounded-lg border border-dark-border bg-dark-card px-3 text-xs font-bold text-brand-primary transition-colors hover:border-brand-primary cursor-pointer"><Pencil className="h-3.5 w-3.5" /> Ajustar saldo</button>}
|
||||
</div>
|
||||
</div>
|
||||
<label className="block text-xs font-bold text-dark-muted">
|
||||
Observações operacionais
|
||||
<textarea value={form.notes} onChange={(event) => setForm(current => ({ ...current, notes: event.target.value }))} placeholder="Opcional" className="mt-1 min-h-24 w-full resize-y rounded-lg border border-dark-border bg-dark-input px-3 py-2 text-sm font-semibold text-dark-text outline-none placeholder:text-dark-muted focus:border-brand-primary" />
|
||||
@@ -2389,9 +2409,12 @@ const MaterialDetailsModal = ({
|
||||
const PurchaseNeedsScreen = () => {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [summary, setSummary] = useState<SupplySummary>(emptySupplySummary);
|
||||
const [stockSnapshot, setStockSnapshot] = useState<StockData[]>([]);
|
||||
const [supplierOptions, setSupplierOptions] = useState<string[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [search, setSearch] = useState('');
|
||||
const [editingMaterial, setEditingMaterial] = useState<SupplyPurchaseNeed | null>(null);
|
||||
const [editingGraphsBalance, setEditingGraphsBalance] = useState<StockData | null>(null);
|
||||
const requestedTab = searchParams.get('tab');
|
||||
const requestedFilter = searchParams.get('filter');
|
||||
const activeTab: PurchaseNeedTab = requestedTab === 'services' ? 'services' : 'materials';
|
||||
@@ -2401,8 +2424,14 @@ const PurchaseNeedsScreen = () => {
|
||||
const [itemsPerPage, setItemsPerPage] = useState(10);
|
||||
|
||||
const loadSummary = async () => {
|
||||
const nextSummary = await fetchSupplySummary();
|
||||
const [nextSummary, nextStockSnapshot, nextSuppliers] = await Promise.all([
|
||||
fetchSupplySummary(),
|
||||
fetchStock(),
|
||||
fetchSupplySuppliers(),
|
||||
]);
|
||||
setSummary(nextSummary);
|
||||
setStockSnapshot(nextStockSnapshot);
|
||||
setSupplierOptions(nextSuppliers);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
@@ -2411,8 +2440,16 @@ const PurchaseNeedsScreen = () => {
|
||||
const load = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const nextSummary = await fetchSupplySummary();
|
||||
if (isMounted) setSummary(nextSummary);
|
||||
const [nextSummary, nextStockSnapshot, nextSuppliers] = await Promise.all([
|
||||
fetchSupplySummary(),
|
||||
fetchStock(),
|
||||
fetchSupplySuppliers(),
|
||||
]);
|
||||
if (isMounted) {
|
||||
setSummary(nextSummary);
|
||||
setStockSnapshot(nextStockSnapshot);
|
||||
setSupplierOptions(nextSuppliers);
|
||||
}
|
||||
} finally {
|
||||
if (isMounted) setIsLoading(false);
|
||||
}
|
||||
@@ -2459,6 +2496,23 @@ const PurchaseNeedsScreen = () => {
|
||||
const visibleNeeds = activeNeeds.filter(need => (
|
||||
!normalizedSearch || normalizeSearch(`${need.material} ${getNeedDisplayName(need)} ${need.suppliers.join(' ')} ${need.colors.join(' ')} ${(need.products || []).map(product => `${product.productId} ${product.name}`).join(' ')}`).includes(normalizedSearch)
|
||||
));
|
||||
const stocksByMaterialKey = useMemo(() => {
|
||||
const index = new Map<string, StockData>();
|
||||
stockSnapshot.forEach(stock => {
|
||||
index.set(normalizeMaterialKey(stock.nome), stock);
|
||||
index.set(normalizeMaterialKey(stock.produto_id), stock);
|
||||
});
|
||||
return index;
|
||||
}, [stockSnapshot]);
|
||||
const linkedEditingStock = editingMaterial
|
||||
? stocksByMaterialKey.get(normalizeMaterialKey(editingMaterial.materialKey)) || stocksByMaterialKey.get(normalizeMaterialKey(editingMaterial.material)) || null
|
||||
: null;
|
||||
const handleGraphsBalanceSaved = (updatedStock: StockData) => {
|
||||
setStockSnapshot(current => current.map(stock => (
|
||||
stock.produto_id === updatedStock.produto_id ? updatedStock : stock
|
||||
)));
|
||||
void loadSummary();
|
||||
};
|
||||
const totalPages = Math.ceil(visibleNeeds.length / itemsPerPage);
|
||||
const safeCurrentPage = Math.min(currentPage, totalPages || 1);
|
||||
const startIndex = (safeCurrentPage - 1) * itemsPerPage;
|
||||
@@ -2581,17 +2635,57 @@ const PurchaseNeedsScreen = () => {
|
||||
<MaterialDetailsModal
|
||||
key={editingMaterial.materialKey}
|
||||
need={editingMaterial}
|
||||
linkedStock={linkedEditingStock}
|
||||
supplierOptions={supplierOptions}
|
||||
onClose={() => setEditingMaterial(null)}
|
||||
onEditGraphsBalance={setEditingGraphsBalance}
|
||||
onSave={async (payload) => {
|
||||
await saveSupplyMaterialDetails(editingMaterial.materialKey, payload);
|
||||
await loadSummary();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{editingGraphsBalance && (
|
||||
<StockGraphsBalanceModal
|
||||
stock={editingGraphsBalance}
|
||||
title={editingMaterial?.material || editingGraphsBalance.nome}
|
||||
onClose={() => setEditingGraphsBalance(null)}
|
||||
onSaved={handleGraphsBalanceSaved}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const PurchaseOrdersScreen = () => {
|
||||
const [orders, setOrders] = useState<SupplyPurchaseOrder[]>([]);
|
||||
const [suppliers, setSuppliers] = useState<string[]>([]);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [message, setMessage] = useState('');
|
||||
const [form, setForm] = useState({ supplier: '', material: '', quantity: '', unit: 'kg', unitCost: '', expectedDate: '', notes: '' });
|
||||
const load = async () => {
|
||||
const [nextOrders, nextSuppliers] = await Promise.all([fetchSupplyPurchaseOrders(), fetchSupplySuppliers()]);
|
||||
setOrders(nextOrders); setSuppliers(nextSuppliers);
|
||||
};
|
||||
useEffect(() => {
|
||||
const timer = window.setTimeout(() => {
|
||||
void load().catch(error => setMessage(error instanceof Error ? error.message : 'Não foi possível carregar as OCs.'));
|
||||
}, 0);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, []);
|
||||
const submit = async (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault(); setBusy(true); setMessage('');
|
||||
try {
|
||||
await createSupplyPurchaseOrder({ supplier: form.supplier, expectedDate: form.expectedDate || null, notes: form.notes, items: [{ material: form.material, quantity: parseDecimal(form.quantity), unit: form.unit, unitCost: form.unitCost ? parseDecimal(form.unitCost) : null }] });
|
||||
setForm({ supplier: '', material: '', quantity: '', unit: 'kg', unitCost: '', expectedDate: '', notes: '' }); await load(); setMessage('Ordem de compra criada. Envie ao fornecedor e atualize o status conforme o andamento.');
|
||||
} catch (error) { setMessage(error instanceof Error ? error.message : 'Não foi possível criar a OC.'); } finally { setBusy(false); }
|
||||
};
|
||||
const changeStatus = async (id: number, status: SupplyPurchaseOrderStatus) => { setBusy(true); try { await updateSupplyPurchaseOrderStatus(id, status); await load(); } catch (error) { setMessage(error instanceof Error ? error.message : 'Não foi possível atualizar a OC.'); } finally { setBusy(false); } };
|
||||
return <div className={pageClassName}><Header title="Ordens de Compra" subtitle="Formalize compras a partir das necessidades de material e acompanhe o compromisso com o fornecedor." backTo="/supplies" />
|
||||
{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="grid grid-cols-1 gap-5 xl:grid-cols-[390px_minmax(0,1fr)]"><form onSubmit={submit} className={`${panelClassName} p-5`}><h2 className="text-base font-bold text-dark-text">Nova ordem de compra</h2><p className="mt-1 text-sm font-semibold text-dark-muted">Uma OC começa com um material. Use uma OC por fornecedor para manter o recebimento rastreável.</p><div className="mt-5 space-y-3"><label className="block text-xs font-bold text-dark-muted">Fornecedor<input required list="purchase-order-suppliers" value={form.supplier} onChange={event => setForm(current => ({ ...current, supplier: event.target.value }))} className={`${inputClassName} mt-1`} placeholder="Selecione ou informe" /></label><datalist id="purchase-order-suppliers">{suppliers.map(supplier => <option key={supplier} value={supplier} />)}</datalist><label className="block text-xs font-bold text-dark-muted">Material<input required value={form.material} onChange={event => setForm(current => ({ ...current, material: event.target.value }))} className={`${inputClassName} mt-1`} placeholder="Ex.: Malha 30.1 preta" /></label><div className="grid grid-cols-2 gap-3"><label className="text-xs font-bold text-dark-muted">Quantidade<input required inputMode="decimal" value={form.quantity} onChange={event => setForm(current => ({ ...current, quantity: event.target.value }))} className={`${inputClassName} mt-1`} placeholder="0" /></label><label className="text-xs font-bold text-dark-muted">Unidade<select value={form.unit} onChange={event => setForm(current => ({ ...current, unit: event.target.value }))} className={`${inputClassName} mt-1`}><option value="kg">kg</option><option value="un.">un.</option><option value="rolos">rolos</option></select></label></div><div className="grid grid-cols-2 gap-3"><label className="text-xs font-bold text-dark-muted">Custo unitário<input inputMode="decimal" value={form.unitCost} onChange={event => setForm(current => ({ ...current, unitCost: event.target.value }))} className={`${inputClassName} mt-1`} placeholder="Opcional" /></label><label className="text-xs font-bold text-dark-muted">Previsão<input type="date" value={form.expectedDate} onChange={event => setForm(current => ({ ...current, expectedDate: event.target.value }))} className={`${inputClassName} mt-1`} /></label></div><label className="block text-xs font-bold text-dark-muted">Observações<textarea value={form.notes} onChange={event => setForm(current => ({ ...current, notes: event.target.value }))} className={`${inputClassName} mt-1 min-h-20`} placeholder="Condição, cor, lote ou referência do fornecedor" /></label><button disabled={busy} className="inline-flex h-11 w-full items-center justify-center gap-2 rounded-lg bg-brand-primary px-4 text-sm font-bold text-brand-contrast disabled:opacity-60 cursor-pointer"><ClipboardList className="h-4 w-4" />Criar OC</button></div></form><div className={panelClassName}><div className="border-b border-dark-border p-5"><h2 className="text-base font-bold text-dark-text">OCs em andamento</h2><p className="mt-1 text-sm font-semibold text-dark-muted">A aprovação financeira e o recebimento físico continuam sendo controlados em Estoque e recebimentos.</p></div>{orders.length ? <div className="divide-y divide-dark-border">{orders.map(order => <div key={order.id} className="p-5"><div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between"><div><p className="font-mono text-xs font-bold text-brand-primary">{order.number}</p><h3 className="mt-1 text-sm font-bold text-dark-text">{order.supplier}</h3><p className="mt-1 text-xs font-semibold text-dark-muted">{order.items.map(item => `${item.material} · ${formatNumber(item.quantity)} ${item.unit}`).join(' | ')}</p></div><select value={order.status} disabled={busy} onChange={event => void changeStatus(order.id, event.target.value as SupplyPurchaseOrderStatus)} className={`${inputClassName} h-9 w-auto text-xs`}><option value="draft">Rascunho</option><option value="sent">Enviada</option><option value="approved">Aprovada</option><option value="received">Recebida</option><option value="canceled">Cancelada</option></select></div><div className="mt-3 flex flex-wrap gap-x-5 gap-y-1 text-xs font-semibold text-dark-muted"><span>Previsão: {order.expectedDate || '-'}</span><span>Total estimado: R$ {formatNumber(order.total, 2)}</span>{order.notes && <span>{order.notes}</span>}</div></div>)}</div> : <div className={emptyStateClassName}><ClipboardList className="h-8 w-8 text-brand-primary" /><h3 className="text-base font-bold text-dark-text">Nenhuma OC criada</h3><p className="text-sm font-semibold text-dark-muted">Use a necessidade de compra para decidir o que pedir e registre a OC aqui.</p></div>}</div></div></div>;
|
||||
};
|
||||
|
||||
const Supplies = () => {
|
||||
const { section } = useParams<{ section?: string }>();
|
||||
|
||||
@@ -2600,6 +2694,7 @@ const Supplies = () => {
|
||||
if (section === 'inventory') return <InventoryScreen />;
|
||||
if (section === 'fabric-planning') return <FabricPlanningScreen />;
|
||||
if (section === 'purchase-needs') return <PurchaseNeedsScreen />;
|
||||
if (section === 'purchase-orders') return <PurchaseOrdersScreen />;
|
||||
|
||||
return <Navigate to="/supplies" replace />;
|
||||
};
|
||||
|
||||
125
src/types.ts
125
src/types.ts
@@ -51,7 +51,7 @@ export type GraphsStockBalanceHistoryItem = {
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export type ProductionOrderStatus = 'open' | 'in_progress' | 'finished' | 'canceled' | string;
|
||||
export type ProductionOrderStatus = 'open' | 'cutting' | 'sewing' | 'review' | 'finished' | 'canceled' | string;
|
||||
|
||||
export interface ProductionOrderMarker {
|
||||
label: string;
|
||||
@@ -78,12 +78,29 @@ export interface ProductionOrderStep {
|
||||
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 ProductionOrderItem {
|
||||
id: number;
|
||||
tinyId: string;
|
||||
number: string;
|
||||
status: ProductionOrderStatus;
|
||||
statusLabel: string;
|
||||
productionPlanId: number | null;
|
||||
productionPlanNumber: string;
|
||||
productionPlanName: string;
|
||||
productionPlanStatus: string;
|
||||
contractor: string;
|
||||
orderReference: string;
|
||||
issueDate: string | null;
|
||||
expectedDate: string | null;
|
||||
@@ -100,6 +117,11 @@ export interface ProductionOrderItem {
|
||||
fabricKg: number | null;
|
||||
ribKg: number | null;
|
||||
yieldPiecesPerKg: number | null;
|
||||
actualCutQuantity: number | null;
|
||||
rejectedQuantity: number;
|
||||
finishedQuantity: number | null;
|
||||
qualityNotes: string;
|
||||
lotReservations: ProductionOrderLotReservation[];
|
||||
markers: ProductionOrderMarker[];
|
||||
components: ProductionOrderComponent[];
|
||||
steps: ProductionOrderStep[];
|
||||
@@ -110,7 +132,9 @@ export interface ProductionOrderItem {
|
||||
export interface ProductionOrderCounts {
|
||||
all: number;
|
||||
open: number;
|
||||
in_progress: number;
|
||||
cutting: number;
|
||||
sewing: number;
|
||||
review: number;
|
||||
finished: number;
|
||||
canceled: number;
|
||||
[key: string]: number;
|
||||
@@ -137,10 +161,68 @@ export type ProductionOrderPayload = {
|
||||
quantity: number;
|
||||
unit?: string;
|
||||
integrationStatus?: string;
|
||||
contractor?: string;
|
||||
components?: Array<{
|
||||
componentTinyId?: string;
|
||||
componentSku?: string;
|
||||
componentName: string;
|
||||
quantityPerUnit: number;
|
||||
totalQuantity?: number;
|
||||
unit?: string;
|
||||
}>;
|
||||
markers?: ProductionOrderMarkerPayload[];
|
||||
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>;
|
||||
createdAt: string | null;
|
||||
updatedAt: string | null;
|
||||
}
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
export interface CreateProductionOrdersResult {
|
||||
created: ProductionOrderItem[];
|
||||
skipped: Array<{
|
||||
@@ -148,6 +230,7 @@ export interface CreateProductionOrdersResult {
|
||||
productDescription?: string;
|
||||
reason: string;
|
||||
}>;
|
||||
plan: ProductionPlan | null;
|
||||
}
|
||||
|
||||
export type CutFamilyKey = 'BLCS' | 'BLOS' | 'BLMC' | 'BLPM' | 'BLRM' | 'OUTROS';
|
||||
@@ -382,6 +465,8 @@ export interface SupplyLot {
|
||||
supplier: string;
|
||||
invoice: string;
|
||||
status: string;
|
||||
reservedQuantity: number;
|
||||
availableQuantity: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
@@ -411,6 +496,36 @@ export interface SupplyFabricPlan {
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export type SupplyPurchaseOrderStatus = 'draft' | '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 {
|
||||
material: string;
|
||||
materialKey: string;
|
||||
@@ -515,6 +630,12 @@ export interface ManagedUser {
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface OperationalUser {
|
||||
id: number | null;
|
||||
name: string;
|
||||
isSystem?: boolean;
|
||||
}
|
||||
|
||||
export interface CreateUserResult {
|
||||
user: ManagedUser;
|
||||
temporaryPassword?: string;
|
||||
|
||||
Reference in New Issue
Block a user