Compare commits

..

2 Commits

Author SHA1 Message Date
Cauê Faleiros
560c089639 feat: export WhatsApp numbers in Clients CSV and make database idempotent
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m4s
2026-05-21 10:35:44 -03:00
Cauê Faleiros
ebc1d7c5ef feat: add cliente_fone column for WhatsApp marketing and safely implement database idempotency 2026-05-21 10:07:11 -03:00
115 changed files with 1414 additions and 35717 deletions

View File

@@ -13,22 +13,12 @@ POSTGRES_DB=graphdb
# --- Application Security ---
# The API key used by n8n to authenticate with the backend
API_KEY=nexstar_secret_key_123
N8N_WHATSAPP_TRIGGER_URL=https://n8n.example.com/webhook/whatsapp-stock
# --- Dashboard Login Credentials ---
ADMIN_EMAIL=admin@admin.com
ADMIN_PASSWORD=admin123
JWT_SECRET=super_secret_jwt_key_123
# --- CAPTCHA / Bot Protection (Optional) ---
# Create keys in Cloudflare Turnstile and set both values in production.
# When TURNSTILE_SECRET is empty, backend CAPTCHA enforcement is disabled.
TURNSTILE_SITE_KEY=
TURNSTILE_SECRET=
# Backward-compatible aliases also accepted by the backend:
# VITE_TURNSTILE_SITE_KEY=
# TURNSTILE_SECRET_KEY=
# --- Frontend Configuration (Optional) ---
# If you need to override the API URL for the frontend
# VITE_API_URL=/api

1
.gitignore vendored
View File

@@ -11,7 +11,6 @@ node_modules
dist
dist-ssr
*.local
.env
# Editor directories and files
.vscode/*

View File

@@ -1,305 +0,0 @@
# Context
## 1. Project Overview
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
**Frontend:**
* **Library:** React 19.2.5
* **Language:** TypeScript 6.0.2
* **Build Tool:** Vite 8.0.10
* **Styling:** Tailwind CSS 4.2.4
* **Icons:** Lucide React 1.14.0
* **Charts:** Recharts 3.8.1
* **Routing:** React Router DOM 7.14.2
**Backend:**
* **Environment:** Node.js
* **Framework:** Express 5.2.1
* **Database:** PostgreSQL (via `pg` 8.20.0)
* **Authentication:** JWT (`jsonwebtoken` 9.0.3)
* **CORS & Middleware:** `cors`, `body-parser`
**Infrastructure & CI/CD:**
* **Containerization:** Docker & Docker Compose
* **Proxy:** Nginx
* **CI/CD:** Gitea Actions (deploy.yml)
* **Automation:** n8n (External trigger source)
## 3. Architecture & Design Decisions
* **Decoupled Client-Server:** The frontend is a statically built SPA served by Nginx, communicating with an isolated Node.js API.
* **Idempotent Database Operations:** Due to potential retry/spam from n8n webhooks, database insertions strictly use `INSERT ... ON CONFLICT DO UPDATE SET` (UPSERTs). The backend dynamically generates fallback IDs using composite keys (`Name_Date_Value`) to prevent historical data squashing when explicit `ID_Pedido` fields are missing.
* **Persistent Campaign Queue:** Stock increases are no longer held in memory. Positive stock deltas are inserted into `stock_campaign_queue`, grouped by normalized base product, and processed later by a scheduled n8n workflow. This survives restarts and lets operators inspect/retry campaign work.
* **Scheduled Campaign Processing:** n8n calls `POST /api/internal/process-stock-campaigns` on fixed schedules, currently intended for 12:00 and 18:00 BRT. The endpoint claims ready product groups where accumulated delta is at least 100, combines all ready products into one campaign payload, and sends it to `N8N_WHATSAPP_TRIGGER_URL`.
* **All-Time Top Buyers:** Campaign targeting uses the top 100 buyers across all order history. There is no date filter in this query. Customers are grouped by `cliente_fone` and ordered by total spend (`SUM(quantidade * valor_unitario)`).
* **Smart Polling vs. SSE:** Real-time UI updates are handled via client-side polling (`setInterval`) rather than Server-Sent Events (SSE) to bypass persistent connection drops caused by production reverse proxies.
* **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 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
/
├── .gitea/workflows/ # CI/CD pipeline definitions
├── backend/ # Node.js Express API
│ ├── Dockerfile # Backend container definition
│ ├── index.js # Backend entry point
│ ├── db.js # PostgreSQL pool and startup schema/migration SQL
│ ├── routes/ # Express route modules
│ ├── services/ # Business logic and campaign processing
│ ├── mappers/ # Payload normalization helpers
│ ├── test/ # Node test runner tests
│ └── package.json
├── public/ # Static assets (Favicons)
├── src/ # React Frontend Application
│ ├── components/ # Reusable UI elements (Layout, DateRangePicker)
│ ├── pages/ # Route-level views (Dashboard, Products, Clients)
│ ├── dataService.ts # Centralized API fetch logic and JWT handling
│ ├── types.ts # Shared TypeScript interfaces
│ └── main.tsx # React entry point
├── docker-compose.yml # Local orchestration and environment variable mapping
├── nginx.conf # Production web server routing
└── vite.config.ts # Frontend build configuration
```
## 5. Core Business Rules & Domain Entities
* **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.
* **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 ...`.
* **Campaign Product List Format:** Because Meta templates collapsed/ignored newlines inside one parameter, `productsText` is a single-line separator list: `Produto 1 • Produto 2 • Produto 3`. There is no leading bullet before the first product.
* **Base Product Normalization:** Apparel size suffixes like `TAMANHO - P`, `- M`, `- G`, `- GG`, and `- M/G/GG` are stripped for campaign grouping. `ETIQUETA...` products are special-cased and preserve their full variant name, e.g. `ETIQUETA BRANCA TAMANHO GG`.
* **Campaign Observability:** The frontend has a `Campanhas` page backed by `/api/campaigns`, `/api/campaigns/preview`, `/api/campaigns/process`, and `/api/campaigns/retry`.
* **WhatsApp Marketing Integration:** The system extracts phone numbers from incoming n8n payloads (checking `Fone_Cliente`, `fone`, or `celular`). Numbers are exposed in the UI for direct "Click-to-Chat" links and exported to CSV files.
* **Filter Persistence:** User preferences for Date Ranges, Sort options, and Auto-Refresh intervals are persisted to `localStorage` to survive page reloads.
* **RFV Segmentation:** The Portuguese UI calls customer segmentation **RFV** (`Recência`, `Frequência`, `Valor`). Internal code/API names may still use `rfm` to avoid route/type churn. The visible app labels should use RFV.
* **RFV Lifecycle Thresholds:** The active lifecycle is monthly, not yearly. Recency score 3 means last purchase in `0-7` days, score 2 means `8-15` days, score 1 starts at `16+` days, and clients with no purchase for `30+` days are forced into `Perdido`.
* **RFV Date Semantics:** Date filters use local calendar-day boundaries and send stable `YYYY-MM-DD` query params. Backend analytics treats `start` and `end` as inclusive `data_pedido_date` bounds.
* **RFV Tag Aggregation Rule:** RFV is not recalculated from only the selected period. For a selected period, the backend first calculates each buyer's RFV tag using history **before the period starts**. It then aggregates only the purchases inside the selected period by that prior tag. Example: if a client was `Champions` before today and buys today, `Champions` gets +1 client and that period revenue. If an `Em Risco` client does not buy today, `Em Risco` does not get counted for today's filter.
* **RFV Display Rule:** The RFV table's `Última Compra` date is the purchase date inside the selected period. Date-only strings must be parsed as local dates in the frontend to avoid UTC shifting (`2026-06-15` must render as `15/06/2026`, not `14/06/2026`). The small recency text below it displays `Hoje`, `Ontem`, or `X dias` relative to the selected range end.
* **Client Page Filters:** The main Clients page has one filter button plus client search. The filter panel contains date shortcuts/custom dates, sorting, marketplace, sales channel, seller, and client type. Marketplace/channel/seller options come from distinct non-empty order metadata across all orders so options remain visible regardless of the current date window.
* **Seller Metadata Display:** Seller labels are cleaned for UI display. All-caps seller names are title-cased, trailing Tiny IDs like `#977226210` are stripped from labels, and leading fantasy-name prefixes like `[3]` are removed for display. IDs may still be used internally as stable filter values.
* **Seller Dashboard Charts:** The main Dashboard includes seller performance charts above the existing charts, using backend aggregates for revenue and order count by seller. Long date ranges are bucketed into week/month views for readability. Seller labels like `0`/empty values are displayed as `Sem vendedor`. The seller moving-average line is optional behind the `Tendência` toggle because it can be confused with another seller series.
* **Product Chart Readability:** Product detail charts use automatic date bucketing, cleaner x-axis labels, clickable point drill-downs, and a moving average on the single-product view. Product detail KPIs include daily average sold and estimated stock coverage based on the selected date range.
## 6. Supply, Cutting, and Data Flow Requirements
Tiny provides sales and stock facts, but it does not fully describe how a finished SKU consumes raw material. For Suprimentos and Corte to work reliably, Graphs needs internal domain data that complements Tiny.
**Current operating assumption:**
* Tiny sends orders, product IDs/names, seller/order metadata, and current stock.
* Graphs derives demand, sales velocity, replenishment pressure, and cut/material needs from Tiny data plus local configuration.
* Excel files are examples of current manual management only. They should guide UX/data modeling but not be imported as the long-term source of truth.
**Core missing relationship:**
```text
Finished SKU -> raw material -> consumption/yield
```
Example:
```text
BASE LISA CAMISETA PRETA G
uses: MALHA 100% ALGODAO PRETA
yield: 4.8 units/kg
```
**Data Graphs must own or enrich locally:**
* **Finished SKU catalog:** SKU/id, name, color, size, product family, cut family, active/inactive.
* **Raw material catalog:** material SKU/id, name, type (`malha`, `ribana`, `fio`, etc.), color, supplier, unit (`kg`, `metro`, `unidade`), and whether it is raw input instead of finished stock.
* **Consumption references:** finished SKU, material product, yield per kg, optional yield by size, optional color/family overrides, and secondary materials when needed.
* **Imported 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.
**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 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 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.
* Known data issues from that export: `4` sellable finished products are missing SKU; `54` structures are raw material/service structures and are stored as compositions but intentionally not promoted into sellable demand-planning references; `236` component/reference lines were skipped because their parent structure was non-sellable or the finished SKU was missing.
* Sellable-family detection currently includes `DTF`, `CAMISETA`, `CAMISA`, `MOLETOM`, `CANGURU`, `REGATA`, and `POLO`. Raw/input/service structures are excluded from demand-reference promotion when descriptions contain terms like `MALHA`, `RIBANA`, `FIO`, `TECIDO`, `SERVIÇO`, `TINTURARIA`, `TECELAGEM`, or `FRETE`.
**Recommended next steps now that composition data exists:**
* Make `Necessidade de Compra` clearer and more auditable: show `demanda do SKU -> consumo por unidade -> material necessário -> estoque atual -> comprar`.
* Add a data-health/review screen for missing finished SKU, missing component stock link, suspicious quantities, products with sales but no composition, and non-sellable structures.
* Connect material stock more explicitly to imported component SKUs/Tiny IDs so material purchase needs subtract the right current stock and pending receipts.
* Upgrade `Planejamento de Corte` to show material blockers by family/color/size, using imported composition references and current stock.
* Add production-plan status flow only after material needs and composition health are reliable.
## 7. Current Operations: Supply, Cutting, Production, and Olist
### 7.1 Suprimentos and PCP
* The six supply views are: the Suprimentos hub, Necessidade de Compra, Plano de Corte, Ordens de Produção, Estoque e Recebimentos, and Planejamento de Malha/Prioridades de Estoque.
* The hub prioritises real material purchases, then active local production orders, then cutting work. It does not elevate missing compositions or negative balances as operational blockers.
* Purchase calculations use actual BOM consumption and the effective planning balance. Negative balances are displayed for audit, but `getAvailablePlanningStock()` clamps availability to zero.
* `Cadastros` paginates Products, Categories, and Consumption References with 10 rows by default. Products, categories, consumption references, and compositions have local maintenance actions. Categories use the same compact action style as other tables.
* The cut plan has a compact demand table, filters, pagination, direct product actions, and a centred configuration dialog. The configuration manages cut-family yields and product corrections without a right-side drawer. Numeric yield inputs hide native browser spinners.
* The OP preview is paginated and selectable. Operators can select individual SKUs, all visible/eligible SKUs, or use the ready-data subset before confirming generation. Existing open local OPs for the same cut plan and SKU are ignored to prevent duplicate local OPs.
* Graphs-created OPs are local records, with incremental display numbers such as `OP-01999`; the number is not random and is not an Olist OP number. An open OP can be returned to the cut plan, restoring its quantity; progressed/finished work must not be returned casually.
* The production-order screen uses a compact list with inline expansion rather than a side detail page. Each OP has one status control (`Em aberto`, `Em andamento`, `Finalizada`, `Cancelada`), and expanded details show product markers, composition, and material consumption context. Material consumption is an OP-level action, relevant once production has started rather than a permanent page-level form.
### 7.2 Olist V3 connection and composition synchronisation
* Graphs has an **Administração** section available to every signed-in user. Its **Olist** monitor at `/#/admin/olist` owns the connection action, manual sync, reconnection, stop action, live status, run history, run logs, and affected-product view; Cadastros does not own the Olist connection flow. The same section includes user management at `/#/admin/users`.
* OAuth uses Olist/Tiny V3 with an authorization code and refresh token. Tokens are encrypted before storage in `olist_connections`; access tokens are refreshed automatically before expiry. The monitor shows the token expiry as an operational status, not an indication that the connection has failed.
* Required production variables are `OLIST_CLIENT_ID`, `OLIST_CLIENT_SECRET`, `OLIST_REDIRECT_URI`, `OLIST_FRONTEND_URL`, `OLIST_TOKEN_ENCRYPTION_KEY`, and `OLIST_SYNC_ENABLED=true`. The encryption key must be stable (a 32-byte base64 key or 64-character hex key): changing it makes already stored tokens unreadable and requires reconnection.
* The OAuth callback route is `GET /api/olist/oauth/callback`; `OLIST_REDIRECT_URI` must be exactly that publicly reachable backend URL. After authorization Graphs redirects to `/#/admin/olist` on `OLIST_FRONTEND_URL`.
* Native sync first lists Olist products, then checks each candidate with `GET /public-api/v3/produtos/{idProduto}/fabricado`. Products with a manufactured BOM are upserted through the existing composition importer; products without a BOM are recorded as checked but do not produce fictional material demand.
* The first run is a full catalogue/BOM scan. Later runs request product changes using `dataAlteracao`; cached products whose structure has not yet been checked remain pending and are resumed before a fully incremental run. This makes a stopped or server-interrupted scan resumable instead of starting the BOM checks over.
* Olist requests are intentionally throttled to a minimum `2,500 ms` apart (`OLIST_SYNC_REQUEST_DELAY_MS`, floor 2500). This is at most 24 requests/minute, deliberately below Tiny's 60 requests/minute limit so other ERP automations retain capacity.
* With `OLIST_SYNC_ENABLED=true`, backend startup schedules the automatic composition sync daily at **11:00 America/Sao_Paulo** (`OLIST_SYNC_HOUR = 11`). It is an in-process scheduler: the backend must be running at that time. Manual sync remains the fallback. The UI reports the next scheduled run and the status/error from the latest run.
* Sync execution is single-run protected by a PostgreSQL advisory lock. Runs, progress, errors, affected products, and events are persisted in `olist_sync_runs` and `olist_sync_events`. The user may request a stop; the worker finishes the current safe step, marks the run cancelled, and later resumes pending structures. Server restart marks a running run failed with an explicit interruption message; pending cache entries still permit a later resume.
* Olist monitor history is paginated with five rows by default. Run-detail event logs and affected-product lists are paginated as well, so status polling does not require reading an unbounded history.
### 7.3 Confirmed Olist API scope and the OP CSV bridge
* **Confirmed by Olist AI/documentation:** V3 and V2 do not provide a supported endpoint to list or read existing Production Orders (OPs), OP status/history, production steps, notes, lots, suppliers, roll quantities, fabric kg, or yield. There is no OP webhook and no incremental OP endpoint. Graphs must not scrape the ERP interface.
* The only production-order endpoint reported by Olist is generation from a sales order (`POST /public-api/v3/pedidos/{idPedido}/gerar-ordem-producao`; V2 equivalent `gerar.ordem.producao.pedido.php`). It does **not** provide subsequent OP reading and is not used as a history feed.
* Product/BOM sync therefore needs only **Products / Read** permission. Additional production permission in the application screen cannot unlock an endpoint that the API does not expose.
* Historical finalized OPs enter Graphs through the manual **Importar OPs finalizadas** CSV bridge. The CSV must include the required columns: `id_op`, `numero_op`, `sku_produto`, `descricao_produto`, `quantidade_produzida`, `data_emissao`, `data_conclusao`, `situacao`, and `observacoes`. Useful optional columns are `pedido_relacionado`, `fornecedor`, `lote`, `quantidade_rolos`, `quilos_malha`, `quilos_ribana`, and `rendimento`.
* The importer accepts only `Finalizada` records with a valid ID, number, SKU, description, and positive quantity. It is idempotent: it matches the existing record by Tiny internal ID or OP number, updating it rather than duplicating it. The import history explicitly reports new, updated, ignored, and failed rows.
* Optional operational values may be provided directly as columns or extracted from `observacoes`. Supported conventions include `FORNECEDOR:`, `RENDIMENTO:`, `BOBINAS:`/`ROLOS:`/`QUANTIDADE DE ROLOS:`, `KG:`, `QUILOS DE MALHA:`, `RIBANA:`, and lot variants such as `Lote 01`, `Lote: 01`, and `- Lote 01`. Bobbin multipliers such as `07(X2)` are recognised. Supplier in this historical OP context is treated as the fabric supplier.
* Importing completed OP history stores the raw operational facts (supplier, lot, rolls, fabric/rib kg, and pieces/kg yield) in `production_orders`, records each file in `production_order_import_runs`, and recalculates yield recommendations. Recommendations are available by family and by family + supplier. The cut configuration shows a suggested **units per roll** only when enough imported factual data exists; using a suggestion is an explicit operator action.
* The current historical-yield aggregation covers `BLCS`, `BLOS`, `BLMC`, `BLPM`, and `BLRM`. Family reading is: `BLCS` Camiseta Regular, `BLOS` Oversize, `BLMC` Moletom Canguru, `BLPM` Camiseta Pima, and `BLRM` Regata Base. `BLRM` is a regata family and must not be grouped with `BLMC` or its moletom materials. Supplier-specific variation is meaningful and should be treated as a recommendation to validate with the cutting team, not an automatic truth.
* Graphs can export a **missing finalized OP data** CSV from its imported history. That file is the correction queue to send to the Olist AI/ERP extraction process. Olist AI exports must be requested in manageable OP-number batches when large note fields cause failures. Re-importing corrected batches is safe because they update the matching OPs.
* The Olist page keeps family recommendation cards visible in both the supplier-reference and imported-file tabs. Supplier references and imported file runs are tables with five rows per page, keeping the previous dense tag wall out of the main workflow.
### 7.4 Supply audit and supplier sources
* Tiny negative balances were investigated as an operational-data problem, typically stock exits without matching entries or historic migration/balance errors. The balance must remain visible for auditing but must not turn into an additional procurement deficit.
* A Graphs physical balance is the way to record a verified count while preserving Tiny's reported balance. The correction must include a reason and is fully auditable through the balance history; it is not a hidden manipulation of imported stock.
* The selectable preferred-supplier list is intentionally built from local material details, lots, receipts, fabric plans, and the imported OP supplier recommendations. It is a controlled Graphs list, not an assumption that Olist exposes a dedicated supplier master through this integration.
## 8. CI/CD & Deployment
* **Gitea Actions:** A workflow located in `.gitea/workflows/deploy.yml` triggers on pushes to the `main` branch.
* **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`.
## 9. Environment Setup & Scripts
**Running Locally:**
1. Start the database:
```bash
docker compose up -d db
```
2. Start the Backend (from `/backend`):
```bash
npm install
npm start
```
3. Start the Frontend (from project root):
```bash
npm install
npm run dev # For HMR development
npm run preview # For production build testing
```
**Building the Frontend:**
```bash
npm run build
```
**Backend Tests:**
```bash
cd backend
npm test
```
**Frontend Date/RFV Helper Test:**
```bash
node --experimental-strip-types --test src/dateRanges.test.ts
```
**Persistent Local Stack:**
```bash
docker compose up -d --build
```
The local Docker services use `restart: unless-stopped`, so containers should come back after laptop restart if Docker starts.
**Importing Tiny/Olist Product Compositions:**
* From the app: open `Cadastros` and use `Importar composições` with the exported JSON file.
* From a script/API client:
```bash
curl -X POST http://localhost:3004/api/production-orders/tiny-compositions/import \
-H 'Content-Type: application/json' \
-H 'x-api-key: nexstar_secret_key_123' \
--data-binary @/path/to/composicoes_produtos_YYYY-MM-DD.json
```
* Expected response includes `imported`, `failed`, `componentCount`, `referenceCount`, `skippedReferenceCount`, and `issues`.
## 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`.
* **Robust Data Handling:** Always implement graceful fallbacks for missing data. Never assume an API payload will contain all keys. (e.g., `item.id || item.ID_Pedido || ''`).
* **Database Migrations:** There is no ORM (like Prisma or Sequelize). Table schemas, indexes, and lightweight data repairs are managed via raw SQL statements inside `initDB()` in `backend/db.js` using `IF NOT EXISTS` and safe startup execution patterns.
* **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.
## 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.
* `ca9615a Clean seller metadata display names` - seller/fantasy labels are normalized for display.
* `b129307 Fix seller filter options query` - seller filter options are selected reliably from order metadata.
* `b40f6fa Restore seller dropdown filter` - seller filter uses a dropdown option list again.
* `aeb801c Allow searching sellers in client filters` - seller filter UI supports searching.
* `b7a2059 Show all client metadata filter options` - client metadata filter options are global, not date-scoped.
* `06faada Refresh client filter options reliably` - filter option fetching was made more robust.
* `7d60073 Smooth analytics page reloads` - analytics fetches now reuse cached/deduped results between pages.
* `335d231 Fix client identity when phone is added` - client grouping prevents phone-added duplicate profiles.
* `73033ca Aggregate RFM period buyers by prior tag` - backend RFV aggregation now groups period buyers by their prior tag.
* `ae73a54 Fix RFM period date display` - frontend date-only display and period recency fixed.
* `9a58b2a Improve RFM recency labels` - period recency labels show `Hoje`/`Ontem`/`X dias`.
* `f811d3c Rename visible RFM labels to RFV` - visible UI wording changed from RFM to RFV.
Files most relevant to RFV:
* `backend/services/analyticsService.js` - `getRfmAnalytics`, date filters, RFV score/tag logic.
* `backend/test/analyticsService.test.js` - unit coverage for date filters and RFV aggregation.
* `src/pages/Rfm.tsx` - RFV page UI, matrix/table/export labels, period recency display.
* `src/components/DateRangePicker.tsx` and `src/dateRanges.ts` - local calendar date ranges and ISO query param formatting.
* `src/dataService.ts` - analytics fetch/cache helpers and client filter option requests.
* `src/displayFormatters.ts` - shared seller/fantasy/client metadata display cleanup.
Recent commits related to supply/cutting/data-flow and chart readability:
* `37d4a77 Import Tiny product compositions` - adds the Tiny/Olist composition JSON import flow, derived `tiny_structure` consumption references, app upload button, API-key import endpoint, and import tests.
* `800eb97 Derive consumption references from Tiny OP sync` - Tiny OP composition sync now creates catalog products/materials and reusable consumption references from OP component lines.
* `c07938c Add Tiny production order detail sync` - 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.
* `9489318 Improve chart readability and drilldowns` - dashboard/product charts gained date bucketing, cleaner unknown labels, click drill-downs, and product stock coverage context.
* `ba9cc3a Fix large sold quantity icon layout` - product/group sold quantity cells handle large values without icon compression.
* `f923489 Make dashboard trend line optional` - dashboard moving-average trend line is hidden by default and available through `Tendência`.
Files most relevant to supply/cutting/data-flow:
* `src/pages/Supplies.tsx` - supply dashboard, receipts, inventory, movements, and purchase-need UI.
* `src/pages/Cutting.tsx` - cut plan, cut settings drawer, SKU correction/deep-link flow.
* `src/pages/Registrations.tsx` - local catalog, raw materials, finished products, and consumption references.
* `src/catalogLinks.ts` - shared deep-link builders for SKU edit, cutting config, and consumption references.
* `src/analytics/cutting.ts` - cut-family parsing, cut needs, stock coverage, and product override logic.
* `src/pages/Products.tsx` and `src/pages/ProductGroupDetails.tsx` - product/group velocity, stock coverage, and high-volume display.
* `src/chartUtils.ts` - shared date bucketing, moving average, unknown-label cleanup, and chart date helpers.
* `backend/services/productionOrderService.js` - 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.

210
README.md
View File

@@ -1,157 +1,73 @@
# Nexstar Graphs
# React + TypeScript + Vite
Real-time sales and stock dashboard for Nexstar. The app receives Tiny ERP data through n8n webhooks, stores it in PostgreSQL, and renders sales, products, clients, stock, and WhatsApp campaign data in a React dashboard.
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
## Stack
Currently, two official plugins are available:
- Frontend: React, TypeScript, Vite, Tailwind CSS, Recharts
- Backend: Node.js, Express, PostgreSQL, JWT, API-key webhook auth
- Runtime: Docker Compose, Nginx, n8n
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
## Main Flows
## React Compiler
### Sales Ingestion
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
```text
n8n -> POST /api/data -> PostgreSQL orders -> dashboard
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
The endpoint accepts a single order item or an array. Requests must include:
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```text
x-api-key: <API_KEY>
Content-Type: application/json
```
### Stock Ingestion
```text
n8n -> POST /api/stock -> PostgreSQL stock + campaign queue
```
Positive stock deltas are queued for WhatsApp campaigns. The scheduled processor groups pending queue rows by base product name and sends a campaign only when the accumulated pending delta reaches at least `100`.
### Scheduled WhatsApp Campaigns
```text
n8n schedule at 12:00/18:00 BRT
-> POST /api/internal/process-stock-campaigns
-> backend calls N8N_WHATSAPP_TRIGGER_URL
-> n8n WhatsApp workflow sends templates
```
The scheduled endpoint is API-key protected and returns a summary:
```json
{
"claimed": 0,
"sentGroups": 0,
"skippedGroups": 0,
"failedGroups": 0,
"pendingBelowThresholdGroups": 0
}
```
### Native Olist V3 composition sync
Graphs can connect directly to Olist V3 from `Cadastros`, import manufactured-product structures, and create/update the local catalog and consumption references. Configure these backend environment variables in Portainer before connecting:
```text
OLIST_CLIENT_ID
OLIST_CLIENT_SECRET
OLIST_REDIRECT_URI=https://<backend-host>/api/olist/oauth/callback
OLIST_FRONTEND_URL=https://<frontend-host>
OLIST_TOKEN_ENCRYPTION_KEY=<32-byte base64 key or 64-character hex key>
OLIST_SYNC_ENABLED=true
```
`OLIST_TOKEN_ENCRYPTION_KEY` must be stable between deploys because Graphs uses it to encrypt OAuth tokens stored in PostgreSQL. The initial sync is full; later scheduled runs are incremental and use the product update date. Manual JSON import remains available as a fallback.
Graphs waits at least 2.5 seconds between every Olist API request (24 requests/minute). This keeps capacity available for other Tiny/Olist integrations. The first catalog scan caches product IDs, SKUs, descriptions, and units; each checked BOM is persisted immediately, so a restarted full sync resumes from the remaining products. Later runs use Olist's `dataAlteracao` filter instead of listing the full catalog. `OLIST_SYNC_REQUEST_DELAY_MS` can increase that delay but cannot lower it below 2500.
### Cutting Workbook Import
Manual cutting/replenishment workbooks can be normalized into JSON before they
are persisted or shown in the app:
```bash
python3 backend/scripts/normalize_cut_workbook.py "/path/to/NECESSIDADE DE CORTE.xlsx" --pretty -o /tmp/necessidade_corte_normalized.json
```
The output includes purchase need rows, production orders, finished stock,
stock plus OP availability, real purchase need, outside items, and cut-plan
sections by family (`BLCS`, `BLMC`, `BLOS`, `BLPM`). The importer reads cached
workbook values and reports broken formula references instead of making the app
depend on Excel formulas at runtime.
## Local Development
Start PostgreSQL:
```bash
docker compose up -d db
```
Start the backend:
```bash
cd backend
npm install
npm start
```
Start the frontend:
```bash
npm install
npm run dev
```
Default local URLs:
```text
Frontend: http://127.0.0.1:3002
Backend: http://127.0.0.1:3004
```
Vite may choose a different frontend port if `3002` is already in use.
## Environment
Copy `.env.example` and configure production secrets in the runtime environment:
```text
POSTGRES_USER
POSTGRES_PASSWORD
POSTGRES_DB
API_KEY
N8N_WHATSAPP_TRIGGER_URL
ADMIN_EMAIL
ADMIN_PASSWORD
JWT_SECRET
TURNSTILE_SITE_KEY
TURNSTILE_SECRET
```
`TURNSTILE_SECRET` enables backend CAPTCHA enforcement on `/api/login`. Set
`TURNSTILE_SITE_KEY` with Cloudflare's public site key so the login page can load
the verification widget at runtime.
The backend also accepts `VITE_TURNSTILE_SITE_KEY` as a site-key alias and
`TURNSTILE_SECRET_KEY` as a secret alias for older deployments.
## Validation
```bash
npm run lint
npm run build
```
For backend syntax checks:
```bash
cd backend
node --check index.js
node --check services/campaignService.js
node --check services/stockService.js
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```

View File

@@ -1,82 +0,0 @@
const jwt = require('jsonwebtoken');
const { ADMIN_EMAIL, ADMIN_PASSWORD, API_KEY, JWT_SECRET } = require('./config');
const { findUserByEmail, normalizeEmail, publicUserFields, verifyPassword } = require('./services/userService');
const verifyToken = (req, res, next) => {
const authHeader = req.headers.authorization;
if (!authHeader) return res.status(403).json({ error: 'No token provided' });
const token = authHeader.split(' ')[1];
if (!token) return res.status(403).json({ error: 'Malformed token' });
jwt.verify(token, JWT_SECRET, (err, decoded) => {
if (err) return res.status(401).json({ error: 'Unauthorized' });
req.user = decoded;
next();
});
};
const verifySuperAdmin = (req, res, next) => {
verifyToken(req, res, () => {
if (req.user?.role !== 'super_admin') {
res.status(403).json({ error: 'Super admin access required' });
return;
}
next();
});
};
const authenticateAPIKey = (req, res, next) => {
const apiKey = req.headers['x-api-key'];
if (apiKey === API_KEY) {
next();
return;
}
res.status(401).json({ error: 'Unauthorized: Invalid API Key' });
};
const buildTokenResponse = (user) => {
const token = jwt.sign(
{
email: user.email,
role: user.role,
userId: user.id || null
},
JWT_SECRET,
{ expiresIn: '24h' }
);
return { token, user };
};
const login = async (email, password) => {
const normalizedEmail = normalizeEmail(email);
if (normalizedEmail === normalizeEmail(ADMIN_EMAIL) && password === ADMIN_PASSWORD) {
return buildTokenResponse({
id: null,
name: 'Super Admin',
email: normalizedEmail,
role: 'super_admin'
});
}
const dbUser = await findUserByEmail(normalizedEmail);
if (!dbUser || !dbUser.is_active || !verifyPassword(password, dbUser.password_hash)) {
return null;
}
return buildTokenResponse({
...publicUserFields(dbUser),
role: 'user'
});
};
module.exports = {
verifyToken,
verifySuperAdmin,
authenticateAPIKey,
login
};

View File

@@ -1,50 +0,0 @@
require('dotenv').config();
const TURNSTILE_KEY_MATCH = /[0-9]x[0-9A-Za-z_-]{20,}/;
const normalizeTurnstileValue = (value) => {
if (typeof value !== 'string') return '';
const trimmedValue = value.trim();
const keyMatch = trimmedValue.match(TURNSTILE_KEY_MATCH);
if (keyMatch) return keyMatch[0];
return trimmedValue.replace(/^[\s"'`{[]+|[\s"'`}\]]+$/g, '');
};
const firstEnvValue = (...values) => values
.map(normalizeTurnstileValue)
.find(Boolean) || '';
const TURNSTILE_SITE_KEY = firstEnvValue(
process.env.TURNSTILE_SITE_KEY,
process.env.TURNSTILE_SITEKEY,
process.env.VITE_TURNSTILE_SITE_KEY,
process.env.CLOUDFLARE_TURNSTILE_SITE_KEY
);
const TURNSTILE_SECRET = firstEnvValue(
process.env.TURNSTILE_SECRET,
process.env.TURNSTILE_SECRET_KEY,
process.env.CLOUDFLARE_TURNSTILE_SECRET
);
module.exports = {
PORT: process.env.PORT || 3004,
API_KEY: process.env.API_KEY || 'nexstar_secret_key_123',
ADMIN_EMAIL: process.env.ADMIN_EMAIL || 'admin@admin.com',
ADMIN_PASSWORD: process.env.ADMIN_PASSWORD || 'admin123',
JWT_SECRET: process.env.JWT_SECRET || 'super_secret_jwt_key_123',
DATABASE_URL: process.env.DATABASE_URL || 'postgres://graphuser:graphpassword@localhost:5432/graphdb',
N8N_WHATSAPP_TRIGGER_URL: process.env.N8N_WHATSAPP_TRIGGER_URL || 'http://localhost:5678/webhook/whatsapp',
OLIST_CLIENT_ID: process.env.OLIST_CLIENT_ID || '',
OLIST_CLIENT_SECRET: process.env.OLIST_CLIENT_SECRET || '',
OLIST_REDIRECT_URI: process.env.OLIST_REDIRECT_URI || '',
OLIST_FRONTEND_URL: process.env.OLIST_FRONTEND_URL || '',
OLIST_TOKEN_ENCRYPTION_KEY: process.env.OLIST_TOKEN_ENCRYPTION_KEY || '',
OLIST_SYNC_ENABLED: process.env.OLIST_SYNC_ENABLED === 'true',
OLIST_SYNC_HOUR: 11,
OLIST_SYNC_REQUEST_DELAY_MS: Math.max(2_500, Number(process.env.OLIST_SYNC_REQUEST_DELAY_MS) || 2_500),
TURNSTILE_SITE_KEY,
TURNSTILE_SECRET
};

View File

@@ -1,960 +0,0 @@
const { Pool } = require('pg');
const { DATABASE_URL } = require('./config');
const pool = new Pool({
connectionString: DATABASE_URL
});
const defaultCatalogCategories = [
['Camiseta regular', 'Bases lisas e camisetas adultas.'],
['Camiseta infantil', 'Produtos infantis por cor e tamanho.'],
['Moletom', 'Moletons, cangurus e produtos de frio.'],
['Oversized', 'Modelagens oversized e variações relacionadas.'],
['Acessórios', 'Bonés, itens complementares e produtos não têxteis.'],
['DTF', 'Insumos e serviços relacionados a impressão DTF.'],
['Malha', 'Tecidos e malhas usados como matéria-prima.'],
['Aviamentos', 'Ribanas, linhas, ilhós e componentes de costura.'],
['Embalagens', 'Sacos, etiquetas, tags e materiais de expedição.'],
['Insumos gerais', 'Materiais de apoio sem família operacional específica.']
];
const seedDefaultCatalogCategories = async () => {
for (const [name, description] of defaultCatalogCategories) {
await pool.query(`
INSERT INTO catalog_categories (name, description, updated_at)
VALUES ($1, $2, CURRENT_TIMESTAMP)
ON CONFLICT (name) DO NOTHING;
`, [name, description]);
}
};
const initDB = async () => {
try {
await pool.query(`SET TIME ZONE 'America/Sao_Paulo';`);
await pool.query(`
CREATE TABLE IF NOT EXISTS orders (
id SERIAL PRIMARY KEY,
cliente_nome VARCHAR(255),
data_pedido VARCHAR(50),
data_pedido_date DATE,
valor_pedido NUMERIC(10, 2),
produto_id VARCHAR(100),
produto_descricao TEXT,
quantidade INTEGER,
valor_unitario NUMERIC(10, 5),
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
`);
await pool.query(`ALTER TABLE orders ADD COLUMN IF NOT EXISTS pedido_id VARCHAR(100);`).catch(() => {});
await pool.query(`ALTER TABLE orders ADD COLUMN IF NOT EXISTS cliente_fone VARCHAR(50);`).catch(() => {});
await pool.query(`ALTER TABLE orders ADD COLUMN IF NOT EXISTS data_pedido_date DATE;`).catch(() => {});
await pool.query(`ALTER TABLE orders ADD COLUMN IF NOT EXISTS cliente_nome_fantasia VARCHAR(255);`).catch(() => {});
await pool.query(`ALTER TABLE orders ADD COLUMN IF NOT EXISTS id_vendedor VARCHAR(100);`).catch(() => {});
await pool.query(`ALTER TABLE orders ADD COLUMN IF NOT EXISTS nome_vendedor VARCHAR(255);`).catch(() => {});
await pool.query(`ALTER TABLE orders ADD COLUMN IF NOT EXISTS marketplace VARCHAR(255);`).catch(() => {});
await pool.query(`ALTER TABLE orders ADD COLUMN IF NOT EXISTS canal_venda VARCHAR(255);`).catch(() => {});
await pool.query(`ALTER TABLE orders ADD COLUMN IF NOT EXISTS numero_ecommerce VARCHAR(100);`).catch(() => {});
await pool.query(`
ALTER TABLE orders
ALTER COLUMN created_at TYPE TIMESTAMPTZ USING created_at AT TIME ZONE 'America/Sao_Paulo',
ALTER COLUMN created_at SET DEFAULT CURRENT_TIMESTAMP;
`).catch(() => {});
await pool.query(`
UPDATE orders
SET data_pedido_date = CASE
WHEN data_pedido ~ '^\\d{4}[-/]\\d{1,2}[-/]\\d{1,2}' THEN to_date(replace(left(data_pedido, 10), '/', '-'), 'YYYY-MM-DD')
WHEN data_pedido ~ '^\\d{1,2}[-/]\\d{1,2}[-/]\\d{4}' THEN to_date(replace(left(data_pedido, 10), '/', '-'), 'DD-MM-YYYY')
ELSE NULL
END
WHERE data_pedido_date IS NULL
AND data_pedido IS NOT NULL
AND data_pedido != '';
`).catch(err => {
console.error('Notice: Could not backfill normalized order dates:', err.message);
});
await pool.query(`CREATE UNIQUE INDEX IF NOT EXISTS unique_order_product ON orders (pedido_id, produto_id);`).catch(err => {
console.error('Notice: Could not create unique index (might already exist or there are duplicates):', err.message);
});
await pool.query(`
CREATE TABLE IF NOT EXISTS stock (
produto_id VARCHAR(100) PRIMARY KEY,
nome TEXT,
saldo INTEGER DEFAULT 0,
delta_estoque INTEGER DEFAULT 0,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
`);
await pool.query(`
ALTER TABLE stock
ALTER COLUMN updated_at TYPE TIMESTAMPTZ USING updated_at AT TIME ZONE 'America/Sao_Paulo',
ALTER COLUMN updated_at SET DEFAULT CURRENT_TIMESTAMP;
`).catch(() => {});
// Tiny/Olist remains the imported source balance. Graphs physical balances
// are stored separately so planning can use a verified count without
// overwriting the audit value received from Tiny.
await pool.query(`
CREATE TABLE IF NOT EXISTS stock_graphs_balances (
produto_id VARCHAR(100) PRIMARY KEY,
balance NUMERIC(14, 4) NOT NULL,
reason TEXT NOT NULL,
effective_at DATE,
updated_by VARCHAR(255),
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
`);
await pool.query(`
CREATE TABLE IF NOT EXISTS stock_graphs_balance_history (
id SERIAL PRIMARY KEY,
produto_id VARCHAR(100) NOT NULL,
tiny_balance NUMERIC(14, 4),
graphs_balance NUMERIC(14, 4),
reason TEXT,
effective_at DATE,
action VARCHAR(20) NOT NULL DEFAULT 'set',
changed_by VARCHAR(255),
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
`);
await pool.query(`
CREATE TABLE IF NOT EXISTS stock_campaign_queue (
id SERIAL PRIMARY KEY,
base_product_name TEXT NOT NULL,
produto_id VARCHAR(100) NOT NULL,
nome TEXT NOT NULL,
saldo INTEGER DEFAULT 0,
delta_estoque INTEGER DEFAULT 0,
status VARCHAR(20) DEFAULT 'pending',
attempts INTEGER DEFAULT 0,
last_error TEXT,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
sent_at TIMESTAMPTZ
);
`);
await pool.query(`
CREATE TABLE IF NOT EXISTS production_orders (
id SERIAL PRIMARY KEY,
tiny_id VARCHAR(100) UNIQUE,
number VARCHAR(100),
status VARCHAR(40) DEFAULT 'open',
order_reference TEXT,
issue_date DATE,
expected_date DATE,
product_sku VARCHAR(255),
product_description TEXT NOT NULL,
quantity NUMERIC(14, 4) DEFAULT 0,
unit VARCHAR(20) DEFAULT 'UN',
integration_status VARCHAR(100),
notes TEXT,
supplier TEXT,
lot_code VARCHAR(120),
roll_quantity NUMERIC(14, 4),
fabric_kg NUMERIC(14, 4),
rib_kg NUMERIC(14, 4),
yield_pieces_per_kg NUMERIC(14, 4),
tiny_payload JSONB,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
`);
// A production plan is the PCP container above the individual OPs. The
// calculated cut screen may change tomorrow; a confirmed plan must not.
await pool.query(`
CREATE TABLE IF NOT EXISTS production_plans (
id SERIAL PRIMARY KEY,
number VARCHAR(40) UNIQUE,
name TEXT NOT NULL,
status VARCHAR(30) NOT NULL DEFAULT 'open',
contractor TEXT,
planned_start_date DATE,
expected_date DATE,
notes TEXT,
source VARCHAR(40) NOT NULL DEFAULT 'cut_plan',
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
completed_at TIMESTAMPTZ
);
`);
// The release decision remains auditable when the plan changes status.
await pool.query(`
CREATE TABLE IF NOT EXISTS production_plan_reviews (
id SERIAL PRIMARY KEY,
production_plan_id INTEGER NOT NULL UNIQUE REFERENCES production_plans(id) ON DELETE CASCADE,
decision VARCHAR(30) NOT NULL DEFAULT 'pending',
checklist JSONB NOT NULL DEFAULT '{}'::jsonb,
notes TEXT,
reviewed_by TEXT,
reviewed_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
`);
await pool.query(`
ALTER TABLE production_orders
ADD COLUMN IF NOT EXISTS production_plan_id INTEGER REFERENCES production_plans(id) ON DELETE SET NULL,
ADD COLUMN IF NOT EXISTS contractor TEXT;
`).catch(() => {});
await pool.query(`
CREATE TABLE IF NOT EXISTS production_order_markers (
id SERIAL PRIMARY KEY,
production_order_id INTEGER NOT NULL REFERENCES production_orders(id) ON DELETE CASCADE,
label VARCHAR(100) NOT NULL,
color VARCHAR(40),
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
UNIQUE (production_order_id, label)
);
`);
await pool.query(`
CREATE TABLE IF NOT EXISTS production_order_components (
id SERIAL PRIMARY KEY,
production_order_id INTEGER NOT NULL REFERENCES production_orders(id) ON DELETE CASCADE,
component_tiny_id VARCHAR(100),
component_sku VARCHAR(255),
component_name TEXT NOT NULL,
quantity_per_unit NUMERIC(14, 4) DEFAULT 0,
total_quantity NUMERIC(14, 4) DEFAULT 0,
unit VARCHAR(30),
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
`);
await pool.query(`
CREATE TABLE IF NOT EXISTS production_order_steps (
id SERIAL PRIMARY KEY,
production_order_id INTEGER NOT NULL REFERENCES production_orders(id) ON DELETE CASCADE,
step_number INTEGER,
name VARCHAR(160) NOT NULL,
start_date DATE,
end_date DATE,
status VARCHAR(80),
color VARCHAR(40),
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
`);
await pool.query(`
CREATE TABLE IF NOT EXISTS 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,
source VARCHAR(60) NOT NULL,
external_source_id VARCHAR(120) NOT NULL,
finished_product_identity VARCHAR(255) NOT NULL,
finished_product_sku VARCHAR(255),
finished_product_description TEXT NOT NULL,
finished_product_unit VARCHAR(30) NOT NULL DEFAULT 'UN',
finished_tiny_product_id VARCHAR(100),
source_metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
last_synced_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
UNIQUE (source, finished_product_identity)
);
`);
await pool.query(`
CREATE TABLE IF NOT EXISTS product_composition_components (
id SERIAL PRIMARY KEY,
product_composition_id INTEGER NOT NULL REFERENCES product_compositions(id) ON DELETE CASCADE,
component_identity VARCHAR(255) NOT NULL,
component_tiny_id VARCHAR(100),
component_sku VARCHAR(255),
component_name TEXT NOT NULL,
quantity_per_unit NUMERIC(14, 4) NOT NULL DEFAULT 0,
unit VARCHAR(30),
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
UNIQUE (product_composition_id, component_identity)
);
`);
await pool.query(`
CREATE TABLE IF NOT EXISTS cutting_family_rules (
family_key VARCHAR(20) PRIMARY KEY,
units_per_roll NUMERIC(14, 4),
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
`);
await pool.query(`
CREATE TABLE IF NOT EXISTS cutting_product_overrides (
product_id VARCHAR(100) PRIMARY KEY,
family_key VARCHAR(20),
color VARCHAR(100),
size VARCHAR(40),
product_type VARCHAR(40),
planning_notes TEXT,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
`);
await pool.query(`
CREATE TABLE IF NOT EXISTS catalog_categories (
id SERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL UNIQUE,
description TEXT,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
`);
await pool.query(`
CREATE TABLE IF NOT EXISTS catalog_products (
id SERIAL PRIMARY KEY,
type VARCHAR(40) NOT NULL DEFAULT 'finished_product',
sku VARCHAR(100) NOT NULL UNIQUE,
name TEXT NOT NULL,
category_id INTEGER REFERENCES catalog_categories(id) ON DELETE SET NULL,
composition TEXT,
notes TEXT,
gramature NUMERIC(14, 4),
material_yield NUMERIC(14, 4),
width_cm NUMERIC(14, 4),
color VARCHAR(100),
subcategory VARCHAR(80),
sizes TEXT[] DEFAULT '{}',
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
`);
await pool.query(`
CREATE TABLE IF NOT EXISTS supply_material_details (
material_key VARCHAR(255) PRIMARY KEY,
display_name TEXT,
preferred_supplier VARCHAR(255),
notes TEXT,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
`);
await pool.query(`
CREATE TABLE IF NOT EXISTS olist_connections (
id SMALLINT PRIMARY KEY CHECK (id = 1),
access_token_ciphertext TEXT NOT NULL,
access_token_iv VARCHAR(64) NOT NULL,
refresh_token_ciphertext TEXT NOT NULL,
refresh_token_iv VARCHAR(64) NOT NULL,
token_expires_at TIMESTAMPTZ,
connected_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
`);
await pool.query(`
CREATE TABLE IF NOT EXISTS olist_oauth_states (
state_hash VARCHAR(128) PRIMARY KEY,
expires_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
`);
await pool.query(`
CREATE TABLE IF NOT EXISTS olist_sync_runs (
id SERIAL PRIMARY KEY,
trigger VARCHAR(30) NOT NULL,
mode VARCHAR(20) NOT NULL,
status VARCHAR(20) NOT NULL,
started_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
completed_at TIMESTAMPTZ,
products_scanned INTEGER NOT NULL DEFAULT 0,
manufactured_products INTEGER NOT NULL DEFAULT 0,
imported INTEGER NOT NULL DEFAULT 0,
component_count INTEGER NOT NULL DEFAULT 0,
reference_count INTEGER NOT NULL DEFAULT 0,
skipped_reference_count INTEGER NOT NULL DEFAULT 0,
failed INTEGER NOT NULL DEFAULT 0,
error_message TEXT,
details JSONB NOT NULL DEFAULT '[]'::jsonb
);
`);
await pool.query(`
ALTER TABLE olist_sync_runs
ADD COLUMN IF NOT EXISTS cancel_requested_at TIMESTAMPTZ;
`);
await pool.query(`
CREATE TABLE IF NOT EXISTS olist_sync_events (
id BIGSERIAL PRIMARY KEY,
run_id INTEGER NOT NULL REFERENCES olist_sync_runs(id) ON DELETE CASCADE,
level VARCHAR(20) NOT NULL DEFAULT 'info',
event_type VARCHAR(40) NOT NULL,
message TEXT NOT NULL,
olist_product_id BIGINT,
sku VARCHAR(100),
product_description TEXT,
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
);
`);
await pool.query(`
CREATE TABLE IF NOT EXISTS olist_product_cache (
olist_product_id BIGINT PRIMARY KEY,
sku VARCHAR(100),
description TEXT NOT NULL,
unit VARCHAR(30) NOT NULL DEFAULT 'UN',
last_changed_at TIMESTAMPTZ,
structure_checked_at TIMESTAMPTZ,
is_manufactured BOOLEAN,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
`);
await pool.query(`
CREATE TABLE IF NOT EXISTS olist_sync_state (
id SMALLINT PRIMARY KEY CHECK (id = 1),
last_catalog_sync_at TIMESTAMPTZ,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
`);
await pool.query(`
CREATE TABLE IF NOT EXISTS consumption_references (
id SERIAL PRIMARY KEY,
product_id INTEGER NOT NULL REFERENCES catalog_products(id) ON DELETE CASCADE,
material_product_id INTEGER REFERENCES catalog_products(id) ON DELETE SET NULL,
color VARCHAR(100),
general_yield NUMERIC(14, 4),
size_yields JSONB DEFAULT '{}'::jsonb,
size_areas JSONB DEFAULT '{}'::jsonb,
gramature NUMERIC(14, 4),
efficiency_percent NUMERIC(7, 3),
rib_g_per_piece NUMERIC(14, 4),
material_cost_per_kg NUMERIC(14, 4),
consumption_quantity NUMERIC(14, 4),
consumption_unit VARCHAR(30),
source VARCHAR(60) DEFAULT 'manual',
last_production_order_id INTEGER REFERENCES production_orders(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
`);
await pool.query(`
CREATE TABLE IF NOT EXISTS supply_receipts (
id SERIAL PRIMARY KEY,
category VARCHAR(120) NOT NULL,
product TEXT NOT NULL,
quantity NUMERIC(14, 4) NOT NULL,
unit VARCHAR(30) NOT NULL DEFAULT 'kg',
supplier TEXT,
invoice VARCHAR(120),
notes TEXT,
status VARCHAR(30) NOT NULL DEFAULT 'pending',
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
approved_at TIMESTAMPTZ
);
`);
await pool.query(`
CREATE TABLE IF NOT EXISTS supply_stock_lots (
id SERIAL PRIMARY KEY,
receipt_id INTEGER REFERENCES supply_receipts(id) ON DELETE SET NULL,
category VARCHAR(120) NOT NULL,
product TEXT NOT NULL,
quantity NUMERIC(14, 4) NOT NULL,
unit VARCHAR(30) NOT NULL DEFAULT 'kg',
supplier TEXT,
invoice VARCHAR(120),
status VARCHAR(30) NOT NULL DEFAULT 'active',
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
`);
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,
receipt_id INTEGER REFERENCES supply_receipts(id) ON DELETE SET NULL,
lot_id INTEGER REFERENCES supply_stock_lots(id) ON DELETE SET NULL,
type VARCHAR(40) NOT NULL,
category VARCHAR(120) NOT NULL,
product TEXT NOT NULL,
quantity NUMERIC(14, 4) NOT NULL,
unit VARCHAR(30) NOT NULL DEFAULT 'kg',
reason TEXT,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
`);
await pool.query(`
CREATE TABLE IF NOT EXISTS supply_fabric_plans (
id SERIAL PRIMARY KEY,
material TEXT NOT NULL,
color VARCHAR(120),
quantity_kg NUMERIC(14, 4) NOT NULL,
supplier TEXT,
priority VARCHAR(40) NOT NULL DEFAULT 'Normal',
status VARCHAR(30) NOT NULL DEFAULT 'active',
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
`);
// A transformation is a planning/execution record, not a stock movement.
// Physical stock remains controlled only by approved receipts and inventory
// adjustments, so a planned dyeing run can never inflate availability.
await pool.query(`
CREATE TABLE IF NOT EXISTS supply_material_transformations (
id SERIAL PRIMARY KEY,
batch_code VARCHAR(120),
stage VARCHAR(40) NOT NULL,
input_material TEXT NOT NULL,
input_quantity_kg NUMERIC(14, 4) NOT NULL CHECK (input_quantity_kg > 0),
output_material TEXT NOT NULL,
output_color VARCHAR(120),
expected_loss_percent NUMERIC(7, 4) NOT NULL DEFAULT 0 CHECK (expected_loss_percent >= 0 AND expected_loss_percent < 100),
expected_output_kg NUMERIC(14, 4) NOT NULL CHECK (expected_output_kg >= 0),
supplier TEXT,
priority VARCHAR(40) NOT NULL DEFAULT 'Normal',
status VARCHAR(30) NOT NULL DEFAULT 'planned',
notes TEXT,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
`);
await pool.query(`CREATE INDEX IF NOT EXISTS idx_supply_material_transformations_status ON supply_material_transformations (status, created_at DESC);`);
await pool.query(`CREATE INDEX IF NOT EXISTS idx_supply_material_transformations_batch ON supply_material_transformations (batch_code, created_at DESC);`);
await pool.query(`
CREATE TABLE IF NOT EXISTS supply_purchase_orders (
id SERIAL PRIMARY KEY,
number VARCHAR(40) UNIQUE,
supplier TEXT NOT NULL,
status VARCHAR(30) NOT NULL DEFAULT 'draft',
expected_date DATE,
notes TEXT,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
`);
await pool.query(`
CREATE TABLE IF NOT EXISTS supply_purchase_order_items (
id SERIAL PRIMARY KEY,
purchase_order_id INTEGER NOT NULL REFERENCES supply_purchase_orders(id) ON DELETE CASCADE,
material TEXT NOT NULL,
quantity NUMERIC(14, 4) NOT NULL CHECK (quantity > 0),
unit VARCHAR(30) NOT NULL DEFAULT 'kg',
unit_cost NUMERIC(14, 4),
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
`);
await pool.query(`CREATE INDEX IF NOT EXISTS idx_supply_purchase_orders_status ON supply_purchase_orders (status, created_at DESC);`);
await pool.query(`
ALTER TABLE supply_receipts
ADD COLUMN IF NOT EXISTS purchase_order_id INTEGER REFERENCES supply_purchase_orders(id) ON DELETE SET NULL;
`).catch(() => {});
await pool.query(`
ALTER TABLE production_orders
ADD COLUMN IF NOT EXISTS notes TEXT,
ADD COLUMN IF NOT EXISTS supplier TEXT,
ADD COLUMN IF NOT EXISTS lot_code VARCHAR(120),
ADD COLUMN IF NOT EXISTS roll_quantity NUMERIC(14, 4),
ADD COLUMN IF NOT EXISTS fabric_kg NUMERIC(14, 4),
ADD COLUMN IF NOT EXISTS rib_kg NUMERIC(14, 4),
ADD COLUMN IF NOT EXISTS yield_pieces_per_kg NUMERIC(14, 4);
`).catch(() => {});
await pool.query(`
ALTER TABLE production_orders
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,
ADD COLUMN IF NOT EXISTS cut_layers INTEGER,
ADD COLUMN IF NOT EXISTS marker_reference VARCHAR(160);
`).catch(() => {});
await pool.query(`
CREATE TABLE IF NOT EXISTS production_order_import_runs (
id SERIAL PRIMARY KEY,
filename TEXT NOT NULL,
status VARCHAR(40) NOT NULL DEFAULT 'running',
total_rows INTEGER NOT NULL DEFAULT 0,
imported_count INTEGER NOT NULL DEFAULT 0,
updated_count INTEGER NOT NULL DEFAULT 0,
skipped_count INTEGER NOT NULL DEFAULT 0,
failed_count INTEGER NOT NULL DEFAULT 0,
error_message TEXT,
started_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
completed_at TIMESTAMPTZ
);
`);
await pool.query(`
CREATE TABLE IF NOT EXISTS production_yield_recommendations (
family_key VARCHAR(20) PRIMARY KEY,
sample_count INTEGER NOT NULL DEFAULT 0,
yield_sample_count INTEGER NOT NULL DEFAULT 0,
roll_sample_count INTEGER NOT NULL DEFAULT 0,
avg_yield_pieces_per_kg NUMERIC(14, 4),
avg_kg_per_roll NUMERIC(14, 4),
recommended_units_per_roll NUMERIC(14, 4),
last_completed_date DATE,
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
);
`);
await pool.query(`
CREATE TABLE IF NOT EXISTS production_yield_supplier_recommendations (
family_key VARCHAR(20) NOT NULL,
supplier VARCHAR(255) NOT NULL,
sample_count INTEGER NOT NULL DEFAULT 0,
yield_sample_count INTEGER NOT NULL DEFAULT 0,
roll_sample_count INTEGER NOT NULL DEFAULT 0,
avg_yield_pieces_per_kg NUMERIC(14, 4),
avg_kg_per_roll NUMERIC(14, 4),
recommended_units_per_roll NUMERIC(14, 4),
last_completed_date DATE,
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (family_key, supplier)
);
`);
await pool.query(`
ALTER TABLE production_orders
ALTER COLUMN created_at TYPE TIMESTAMPTZ USING created_at AT TIME ZONE 'America/Sao_Paulo',
ALTER COLUMN created_at SET DEFAULT CURRENT_TIMESTAMP,
ALTER COLUMN updated_at TYPE TIMESTAMPTZ USING updated_at AT TIME ZONE 'America/Sao_Paulo',
ALTER COLUMN updated_at SET DEFAULT CURRENT_TIMESTAMP;
`).catch(() => {});
await pool.query(`
ALTER TABLE production_order_components
ALTER COLUMN created_at TYPE TIMESTAMPTZ USING created_at AT TIME ZONE 'America/Sao_Paulo',
ALTER COLUMN created_at SET DEFAULT CURRENT_TIMESTAMP,
ALTER COLUMN updated_at TYPE TIMESTAMPTZ USING updated_at AT TIME ZONE 'America/Sao_Paulo',
ALTER COLUMN updated_at SET DEFAULT CURRENT_TIMESTAMP;
`).catch(() => {});
await pool.query(`
ALTER TABLE production_order_steps
ALTER COLUMN created_at TYPE TIMESTAMPTZ USING created_at AT TIME ZONE 'America/Sao_Paulo',
ALTER COLUMN created_at SET DEFAULT CURRENT_TIMESTAMP,
ALTER COLUMN updated_at TYPE TIMESTAMPTZ USING updated_at AT TIME ZONE 'America/Sao_Paulo',
ALTER COLUMN updated_at SET DEFAULT CURRENT_TIMESTAMP;
`).catch(() => {});
await pool.query(`
ALTER TABLE cutting_family_rules
ALTER COLUMN updated_at TYPE TIMESTAMPTZ USING updated_at AT TIME ZONE 'America/Sao_Paulo',
ALTER COLUMN updated_at SET DEFAULT CURRENT_TIMESTAMP;
`).catch(() => {});
await pool.query(`
ALTER TABLE cutting_product_overrides
ALTER COLUMN updated_at TYPE TIMESTAMPTZ USING updated_at AT TIME ZONE 'America/Sao_Paulo',
ALTER COLUMN updated_at SET DEFAULT CURRENT_TIMESTAMP;
`).catch(() => {});
await pool.query(`
ALTER TABLE cutting_product_overrides
ADD COLUMN IF NOT EXISTS product_type VARCHAR(40),
ADD COLUMN IF NOT EXISTS planning_notes TEXT;
`).catch(() => {});
await pool.query(`
ALTER TABLE catalog_categories
ALTER COLUMN created_at TYPE TIMESTAMPTZ USING created_at AT TIME ZONE 'America/Sao_Paulo',
ALTER COLUMN created_at SET DEFAULT CURRENT_TIMESTAMP,
ALTER COLUMN updated_at TYPE TIMESTAMPTZ USING updated_at AT TIME ZONE 'America/Sao_Paulo',
ALTER COLUMN updated_at SET DEFAULT CURRENT_TIMESTAMP;
`).catch(() => {});
await pool.query(`
ALTER TABLE catalog_products
ALTER COLUMN created_at TYPE TIMESTAMPTZ USING created_at AT TIME ZONE 'America/Sao_Paulo',
ALTER COLUMN created_at SET DEFAULT CURRENT_TIMESTAMP,
ALTER COLUMN updated_at TYPE TIMESTAMPTZ USING updated_at AT TIME ZONE 'America/Sao_Paulo',
ALTER COLUMN updated_at SET DEFAULT CURRENT_TIMESTAMP;
`).catch(() => {});
await pool.query(`
ALTER TABLE consumption_references
ADD COLUMN IF NOT EXISTS consumption_quantity NUMERIC(14, 4),
ADD COLUMN IF NOT EXISTS consumption_unit VARCHAR(30),
ADD COLUMN IF NOT EXISTS source VARCHAR(60) DEFAULT 'manual',
ADD COLUMN IF NOT EXISTS last_production_order_id INTEGER REFERENCES production_orders(id) ON DELETE SET NULL;
`).catch(() => {});
await pool.query(`
ALTER TABLE consumption_references
ALTER COLUMN created_at TYPE TIMESTAMPTZ USING created_at AT TIME ZONE 'America/Sao_Paulo',
ALTER COLUMN created_at SET DEFAULT CURRENT_TIMESTAMP,
ALTER COLUMN updated_at TYPE TIMESTAMPTZ USING updated_at AT TIME ZONE 'America/Sao_Paulo',
ALTER COLUMN updated_at SET DEFAULT CURRENT_TIMESTAMP;
`).catch(() => {});
await seedDefaultCatalogCategories();
await pool.query(`
ALTER TABLE supply_receipts
ALTER COLUMN created_at TYPE TIMESTAMPTZ USING created_at AT TIME ZONE 'America/Sao_Paulo',
ALTER COLUMN created_at SET DEFAULT CURRENT_TIMESTAMP,
ALTER COLUMN updated_at TYPE TIMESTAMPTZ USING updated_at AT TIME ZONE 'America/Sao_Paulo',
ALTER COLUMN updated_at SET DEFAULT CURRENT_TIMESTAMP,
ALTER COLUMN approved_at TYPE TIMESTAMPTZ USING approved_at AT TIME ZONE 'America/Sao_Paulo';
`).catch(() => {});
await pool.query(`
ALTER TABLE supply_stock_lots
ALTER COLUMN created_at TYPE TIMESTAMPTZ USING created_at AT TIME ZONE 'America/Sao_Paulo',
ALTER COLUMN created_at SET DEFAULT CURRENT_TIMESTAMP,
ALTER COLUMN updated_at TYPE TIMESTAMPTZ USING updated_at AT TIME ZONE 'America/Sao_Paulo',
ALTER COLUMN updated_at SET DEFAULT CURRENT_TIMESTAMP;
`).catch(() => {});
await pool.query(`
ALTER TABLE supply_movements
ALTER COLUMN created_at TYPE TIMESTAMPTZ USING created_at AT TIME ZONE 'America/Sao_Paulo',
ALTER COLUMN created_at SET DEFAULT CURRENT_TIMESTAMP;
`).catch(() => {});
await pool.query(`
ALTER TABLE supply_fabric_plans
ALTER COLUMN created_at TYPE TIMESTAMPTZ USING created_at AT TIME ZONE 'America/Sao_Paulo',
ALTER COLUMN created_at SET DEFAULT CURRENT_TIMESTAMP,
ALTER COLUMN updated_at TYPE TIMESTAMPTZ USING updated_at AT TIME ZONE 'America/Sao_Paulo',
ALTER COLUMN updated_at SET DEFAULT CURRENT_TIMESTAMP;
`).catch(() => {});
await pool.query(`
CREATE TABLE IF NOT EXISTS app_users (
id SERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
email VARCHAR(255) NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
`);
await pool.query(`
CREATE TABLE IF NOT EXISTS client_identity_tokens (
customer_key TEXT PRIMARY KEY,
token TEXT NOT NULL UNIQUE,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
`);
await pool.query(`
ALTER TABLE app_users
ALTER COLUMN created_at TYPE TIMESTAMPTZ USING created_at AT TIME ZONE 'America/Sao_Paulo',
ALTER COLUMN created_at SET DEFAULT CURRENT_TIMESTAMP,
ALTER COLUMN updated_at TYPE TIMESTAMPTZ USING updated_at AT TIME ZONE 'America/Sao_Paulo',
ALTER COLUMN updated_at SET DEFAULT CURRENT_TIMESTAMP;
`).catch(() => {});
// 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',
ALTER COLUMN created_at SET DEFAULT CURRENT_TIMESTAMP,
ALTER COLUMN updated_at TYPE TIMESTAMPTZ USING updated_at AT TIME ZONE 'America/Sao_Paulo',
ALTER COLUMN updated_at SET DEFAULT CURRENT_TIMESTAMP,
ALTER COLUMN sent_at TYPE TIMESTAMPTZ USING sent_at AT TIME ZONE 'America/Sao_Paulo';
`).catch(() => {});
await pool.query(`
UPDATE stock_campaign_queue
SET base_product_name = TRIM(regexp_replace(
base_product_name,
'\\s+-\\s+(?:(?:PP|P|M|G|GG|XG|XGG|EG|EGG|EXG|U|UNICO|ÚNICO|\\d{2})(?:/(?:PP|P|M|G|GG|XG|XGG|EG|EGG|EXG|U|UNICO|ÚNICO|\\d{2}))*)$',
'',
'i'
))
WHERE status IN ('pending', 'failed', 'processing')
AND base_product_name ~* '\\s+-\\s+(?:(?:PP|P|M|G|GG|XG|XGG|EG|EGG|EXG|U|UNICO|ÚNICO|\\d{2})(?:/(?:PP|P|M|G|GG|XG|XGG|EG|EGG|EXG|U|UNICO|ÚNICO|\\d{2}))*)$';
`).catch(err => {
console.error('Notice: Could not normalize queued campaign product names:', err.message);
});
await pool.query(`
UPDATE stock_campaign_queue
SET base_product_name = nome
WHERE status IN ('pending', 'failed', 'processing')
AND nome ILIKE 'ETIQUETA%'
AND base_product_name != nome;
`).catch(err => {
console.error('Notice: Could not restore queued etiqueta product names:', err.message);
});
await pool.query(`CREATE INDEX IF NOT EXISTS idx_stock_campaign_queue_status ON stock_campaign_queue (status);`);
await pool.query(`CREATE INDEX IF NOT EXISTS idx_stock_graphs_balance_history_product_created ON stock_graphs_balance_history (produto_id, created_at DESC);`);
await pool.query(`CREATE INDEX IF NOT EXISTS idx_production_orders_status ON production_orders (status);`);
await pool.query(`CREATE INDEX IF NOT EXISTS idx_production_orders_issue_date ON production_orders (issue_date DESC);`);
await pool.query(`CREATE INDEX IF NOT EXISTS idx_production_orders_expected_date ON production_orders (expected_date DESC);`);
await pool.query(`CREATE INDEX IF NOT EXISTS idx_production_orders_completed_date ON production_orders (completed_date DESC);`);
await pool.query(`CREATE INDEX IF NOT EXISTS idx_production_order_import_runs_started_at ON production_order_import_runs (started_at DESC);`);
await pool.query(`CREATE INDEX IF NOT EXISTS idx_production_order_markers_order_id ON production_order_markers (production_order_id);`);
await pool.query(`CREATE INDEX IF NOT EXISTS idx_production_order_components_order_id ON production_order_components (production_order_id);`);
await pool.query(`CREATE INDEX IF NOT EXISTS idx_production_order_components_sku ON production_order_components (component_sku);`);
await pool.query(`CREATE INDEX IF NOT EXISTS idx_production_order_steps_order_id ON production_order_steps (production_order_id);`);
await pool.query(`CREATE INDEX IF NOT EXISTS idx_product_compositions_finished_tiny_id ON product_compositions (finished_tiny_product_id);`);
await pool.query(`CREATE INDEX IF NOT EXISTS idx_product_composition_components_tiny_id ON product_composition_components (component_tiny_id);`);
await pool.query(`CREATE INDEX IF NOT EXISTS idx_cutting_product_overrides_family_key ON cutting_product_overrides (family_key);`);
await pool.query(`CREATE INDEX IF NOT EXISTS idx_catalog_products_type ON catalog_products (type);`);
await pool.query(`CREATE INDEX IF NOT EXISTS idx_catalog_products_category_id ON catalog_products (category_id);`);
await pool.query(`CREATE INDEX IF NOT EXISTS idx_olist_sync_runs_started_at ON olist_sync_runs (started_at DESC);`);
await pool.query(`CREATE INDEX IF NOT EXISTS idx_olist_sync_events_run_created ON olist_sync_events (run_id, created_at DESC);`);
await pool.query(`CREATE INDEX IF NOT EXISTS idx_olist_sync_events_affected_products ON olist_sync_events (run_id, event_type, created_at DESC);`);
await pool.query(`CREATE INDEX IF NOT EXISTS idx_olist_product_cache_pending_structure ON olist_product_cache (structure_checked_at) WHERE structure_checked_at IS NULL;`);
await pool.query(`CREATE INDEX IF NOT EXISTS idx_consumption_references_product_id ON consumption_references (product_id);`);
await pool.query(`CREATE INDEX IF NOT EXISTS idx_consumption_references_material_product_id ON consumption_references (material_product_id);`);
await pool.query(`
CREATE UNIQUE INDEX IF NOT EXISTS unique_consumption_reference_source
ON consumption_references (
product_id,
COALESCE(material_product_id, 0),
COALESCE(color, ''),
COALESCE(source, 'manual')
);
`).catch(err => {
console.error('Notice: Could not create unique consumption reference source index:', err.message);
});
await pool.query(`CREATE INDEX IF NOT EXISTS idx_supply_receipts_status ON supply_receipts (status);`);
await pool.query(`CREATE INDEX IF NOT EXISTS idx_supply_receipts_created_at ON supply_receipts (created_at DESC);`);
await pool.query(`CREATE INDEX IF NOT EXISTS idx_supply_stock_lots_status ON supply_stock_lots (status);`);
await pool.query(`CREATE INDEX IF NOT EXISTS idx_supply_movements_created_at ON supply_movements (created_at DESC);`);
await pool.query(`CREATE INDEX IF NOT EXISTS idx_supply_fabric_plans_status ON supply_fabric_plans (status);`);
await pool.query(`CREATE INDEX IF NOT EXISTS idx_orders_cliente_fone ON orders (cliente_fone);`);
await pool.query(`
CREATE INDEX IF NOT EXISTS idx_orders_normalized_cliente_nome
ON orders ((NULLIF(LOWER(TRIM(regexp_replace(COALESCE(cliente_nome, ''), '\\s+', ' ', 'g'))), '')));
`).catch(err => {
console.error('Notice: Could not create normalized client name index:', err.message);
});
await pool.query(`
CREATE INDEX IF NOT EXISTS idx_orders_normalized_cliente_nome_phone_date
ON orders (
(NULLIF(LOWER(TRIM(regexp_replace(COALESCE(cliente_nome, ''), '\\s+', ' ', 'g'))), '')),
data_pedido_date DESC,
id DESC
)
WHERE NULLIF(cliente_fone, '') IS NOT NULL;
`).catch(err => {
console.error('Notice: Could not create normalized client phone lookup index:', err.message);
});
await pool.query(`
CREATE INDEX IF NOT EXISTS idx_orders_customer_key_date
ON orders (
(COALESCE(NULLIF(cliente_fone, ''), 'name:' || COALESCE(NULLIF(cliente_nome, ''), 'Cliente Desconhecido'))),
data_pedido_date
);
`);
await pool.query(`CREATE INDEX IF NOT EXISTS idx_orders_produto_id ON orders (produto_id);`);
await pool.query(`CREATE INDEX IF NOT EXISTS idx_orders_data_pedido_date ON orders (data_pedido_date);`);
await pool.query(`CREATE INDEX IF NOT EXISTS idx_app_users_email ON app_users (LOWER(email));`);
console.log('Database initialized successfully.');
} catch (err) {
console.error('Failed to initialize database:', err);
throw err;
}
};
module.exports = {
pool,
initDB
};

View File

@@ -1,22 +1,187 @@
const { createApp } = require('./server');
const { initDB } = require('./db');
const { OLIST_SYNC_ENABLED, PORT } = require('./config');
const { markInterruptedOlistRuns, scheduleOlistSync } = require('./services/olistService');
const express = require('express');
const cors = require('cors');
const bodyParser = require('body-parser');
const { Pool } = require('pg');
const jwt = require('jsonwebtoken');
require('dotenv').config();
const start = async () => {
await initDB();
await markInterruptedOlistRuns();
const app = express();
const PORT = process.env.PORT || 3004;
const API_KEY = process.env.API_KEY || "nexstar_secret_key_123";
const app = createApp();
if (OLIST_SYNC_ENABLED) await scheduleOlistSync();
app.listen(PORT, '0.0.0.0', () => {
console.log(`Nexstar Backend running at http://localhost:${PORT}`);
console.log(`Endpoint for n8n: POST http://localhost:${PORT}/api/data`);
console.log(`Scheduled campaign processor: POST http://localhost:${PORT}/api/internal/process-stock-campaigns`);
// Admin Credentials
const ADMIN_EMAIL = process.env.ADMIN_EMAIL || 'admin@admin.com';
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || 'admin123';
const JWT_SECRET = process.env.JWT_SECRET || 'super_secret_jwt_key_123';
app.use(cors());
app.use(bodyParser.json());
// PostgreSQL Connection Pool
const pool = new Pool({
connectionString: process.env.DATABASE_URL || 'postgres://graphuser:graphpassword@localhost:5432/graphdb',
});
// Initialize Database Table
const initDB = async () => {
try {
await pool.query(`
CREATE TABLE IF NOT EXISTS orders (
id SERIAL PRIMARY KEY,
cliente_nome VARCHAR(255),
data_pedido VARCHAR(50),
valor_pedido NUMERIC(10, 2),
produto_id VARCHAR(100),
produto_descricao TEXT,
quantidade INTEGER,
valor_unitario NUMERIC(10, 5),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
`);
await pool.query(`ALTER TABLE orders ADD COLUMN IF NOT EXISTS pedido_id VARCHAR(100);`).catch(() => {});
await pool.query(`CREATE UNIQUE INDEX IF NOT EXISTS unique_order_product ON orders (pedido_id, produto_id);`).catch(err => {
console.error("Notice: Could not create unique index (might already exist or there are duplicates):", err.message);
});
console.log("Database initialized successfully.");
} catch (err) {
console.error("Failed to initialize database:", err);
}
};
initDB();
// Middleware for Frontend Authentication
const verifyToken = (req, res, next) => {
const authHeader = req.headers['authorization'];
if (!authHeader) return res.status(403).json({ error: 'No token provided' });
const token = authHeader.split(' ')[1];
if (!token) return res.status(403).json({ error: 'Malformed token' });
jwt.verify(token, JWT_SECRET, (err, decoded) => {
if (err) return res.status(401).json({ error: 'Unauthorized' });
req.user = decoded;
next();
});
};
start().catch((error) => {
console.error('Failed to start backend:', error);
process.exit(1);
// Login Endpoint
app.post('/api/login', (req, res) => {
const { email, password } = req.body;
if (email === ADMIN_EMAIL && password === ADMIN_PASSWORD) {
const token = jwt.sign({ email }, JWT_SECRET, { expiresIn: '24h' });
res.json({ token });
} else {
res.status(401).json({ error: 'Invalid credentials' });
}
});
// Helper to format rows to match the old JSON structure for the frontend
const formatRow = (row) => ({
Nome_Cliente: row.cliente_nome,
Data_Pedido: row.data_pedido,
Valor_Pedido: parseFloat(row.valor_pedido),
ID_Produto: row.produto_id,
Descricao_Produto: row.produto_descricao,
Quantidade: row.quantidade,
Valor_Unitario: parseFloat(row.valor_unitario),
Recebido_Em: row.created_at,
ID_Pedido: row.pedido_id,
Fone_Cliente: row.cliente_fone
});
// GET data (for the frontend)
app.get('/api/data', verifyToken, async (req, res) => {
try {
const result = await pool.query('SELECT * FROM orders ORDER BY id DESC');
const formattedData = result.rows.map(formatRow);
res.json(formattedData);
} catch (error) {
console.error("Error fetching data:", error);
res.status(500).json({ error: 'Internal Server Error' });
}
});
// POST data (for n8n) - Protected by API_KEY internally or via middleware if needed
// Leaving it as it was, checking API_KEY manually? Wait, the previous version didn't actually use 'authenticate' middleware on the POST!
// Let's add the authenticate middleware to the POST endpoint.
const authenticateAPIKey = (req, res, next) => {
const apiKey = req.headers['x-api-key'];
if (apiKey === API_KEY) {
next();
} else {
res.status(401).json({ error: 'Unauthorized: Invalid API Key' });
}
};
app.post('/api/data', authenticateAPIKey, async (req, res) => {
// Respond IMMEDIATELY to prevent slowing down n8n / WhatsApp flows
res.status(201).json({ message: 'Data received, processing in background' });
const newData = req.body;
const payload = Array.isArray(newData) ? newData : [newData];
// Process asynchronously
(async () => {
const client = await pool.connect();
try {
await client.query('BEGIN');
const insertQuery = `
INSERT INTO orders (
cliente_nome, data_pedido, valor_pedido,
produto_id, produto_descricao, quantidade, valor_unitario, pedido_id, cliente_fone
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
ON CONFLICT (pedido_id, produto_id) DO UPDATE SET
cliente_nome = EXCLUDED.cliente_nome,
data_pedido = EXCLUDED.data_pedido,
valor_pedido = EXCLUDED.valor_pedido,
produto_descricao = EXCLUDED.produto_descricao,
quantidade = EXCLUDED.quantidade,
valor_unitario = EXCLUDED.valor_unitario,
cliente_fone = EXCLUDED.cliente_fone,
created_at = CURRENT_TIMESTAMP
`;
for (const item of payload) {
// Handle potential missing fields gracefully
const fallbackId = `${item.Nome_Cliente}_${item.Data_Pedido}_${item.Valor_Pedido}`;
const orderId = item.id || item.ID_Pedido || (item.json && item.json.body && item.json.body.id) || fallbackId;
const fone = item.Fone_Cliente || item.fone || item.celular || '';
const values = [
item.Nome_Cliente || 'Unknown',
item.Data_Pedido || '',
parseFloat(item.Valor_Pedido) || 0,
item.ID_Produto || '',
item.Descricao_Produto || '',
parseInt(item.Quantidade) || 0,
parseFloat(item.Valor_Unitario) || 0,
String(orderId),
String(fone)
];
await client.query(insertQuery, values);
}
await client.query('COMMIT');
} catch (error) {
await client.query('ROLLBACK');
console.error("Database insert error:", error);
} finally {
client.release();
}
})();
});
app.listen(PORT, '0.0.0.0', () => {
console.log(`Nexstar Backend running at http://localhost:${PORT}`);
console.log(`Endpoint for n8n: POST http://localhost:${PORT}/api/data`);
});;
app.listen(PORT, '0.0.0.0', () => {
console.log(`Nexstar Backend running at http://localhost:${PORT}`);
console.log(`Endpoint for n8n: POST http://localhost:${PORT}/api/data`);
});

View File

@@ -1,85 +0,0 @@
const formatOrderRow = (row) => ({
Nome_Cliente: row.cliente_nome,
Data_Pedido: row.data_pedido,
Valor_Pedido: parseFloat(row.valor_pedido),
ID_Produto: row.produto_id,
Descricao_Produto: row.produto_descricao,
Quantidade: row.quantidade,
Valor_Unitario: parseFloat(row.valor_unitario),
Recebido_Em: row.created_at,
ID_Pedido: row.pedido_id,
Fone_Cliente: row.cliente_fone,
cliente_nome_fantasia: row.cliente_nome_fantasia || '',
id_vendedor: row.id_vendedor || '',
nome_vendedor: row.nome_vendedor || '',
marketplace: row.marketplace || '',
canal_venda: row.canal_venda || '',
numero_ecommerce: row.numero_ecommerce || ''
});
const normalizeOrderDate = (dateValue) => {
if (!dateValue) return null;
const value = String(dateValue).trim();
const match = value.match(/^(\d{1,4})[-/](\d{1,2})[-/](\d{1,4})/);
if (!match) return null;
const [, first, second, third] = match;
const year = first.length === 4 ? Number(first) : Number(third);
const month = Number(second);
const day = first.length === 4 ? Number(third) : Number(first);
const date = new Date(Date.UTC(year, month - 1, day));
if (
date.getUTCFullYear() !== year ||
date.getUTCMonth() !== month - 1 ||
date.getUTCDate() !== day
) {
return null;
}
return date.toISOString().slice(0, 10);
};
const pickFirstValue = (item, fieldNames) => {
for (const fieldName of fieldNames) {
const value = item[fieldName];
if (value !== undefined && value !== null && value !== '') {
return value;
}
}
return '';
};
const normalizeOrderPayload = (item) => {
const fallbackId = `${item.Nome_Cliente}_${item.Data_Pedido}_${item.Valor_Pedido}`;
const orderId = item.id || item.ID_Pedido || (item.json && item.json.body && item.json.body.id) || fallbackId;
const fone = item.Fone_Cliente || item.fone || item.celular || '';
const orderDate = item.Data_Pedido || '';
return [
item.Nome_Cliente || 'Unknown',
orderDate,
normalizeOrderDate(orderDate),
parseFloat(item.Valor_Pedido) || 0,
item.ID_Produto || '',
item.Descricao_Produto || '',
parseInt(item.Quantidade, 10) || 0,
parseFloat(item.Valor_Unitario) || 0,
String(orderId),
String(fone),
String(pickFirstValue(item, ['nome_fantasia', 'Nome_Fantasia', 'Cliente_Nome_Fantasia', 'cliente_nome_fantasia'])),
String(pickFirstValue(item, ['id_vendedor', 'ID_Vendedor'])),
String(pickFirstValue(item, ['nome_vendedor', 'Nome_Vendedor'])),
String(pickFirstValue(item, ['marketplace', 'Marketplace', 'nome_ecommerce', 'Nome_Ecommerce'])),
String(pickFirstValue(item, ['canal_venda', 'Canal_Venda'])),
String(pickFirstValue(item, ['numero_ecommerce', 'Numero_Ecommerce']))
];
};
module.exports = {
formatOrderRow,
normalizeOrderDate,
normalizeOrderPayload
};

View File

@@ -1,31 +0,0 @@
const SIZE_SUFFIX_PATTERN = /\s+-\s+(?:(?:PP|P|M|G|GG|XG|XGG|EG|EGG|EXG|U|UNICO|ÚNICO|\d{2})(?:\/(?:PP|P|M|G|GG|XG|XGG|EG|EGG|EXG|U|UNICO|ÚNICO|\d{2}))*)$/i;
const getBaseProductName = (name) => {
const productName = String(name || 'Unknown').trim();
if (productName.toLocaleUpperCase('pt-BR').startsWith('ETIQUETA')) {
return productName;
}
return productName
.split(' TAMANHO')[0]
.replace(SIZE_SUFFIX_PATTERN, '')
.trim();
};
const normalizeStockPayload = (item) => {
const produtoId = item.idProduto || item.ID_Produto || '';
const nome = item.nome || item.Descricao_Produto || 'Unknown';
return {
produtoId: String(produtoId),
nome,
baseProductName: getBaseProductName(nome),
saldo: parseInt(item.saldo, 10) || 0,
deltaEstoque: parseInt(item.delta_estoque, 10) || 0
};
};
module.exports = {
getBaseProductName,
normalizeStockPayload
};

View File

@@ -5,8 +5,7 @@
"main": "index.js",
"scripts": {
"start": "node index.js",
"seed:supplies-demo": "node scripts/seedSuppliesDemo.js",
"test": "node --test"
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",

View File

@@ -1,178 +0,0 @@
const express = require('express');
const { verifyToken } = require('../auth');
const {
getClientAnalytics,
getClientDetailsAnalytics,
getClientFilterOptions,
getClientPurchasePatternAnalytics,
getDashboardAnalytics,
getSellerAnalytics,
getSellerDetailsAnalytics,
getProductAnalytics,
getProductDetailsAnalytics,
getRfmAnalytics
} = require('../services/analyticsService');
const {
deleteManualProductComposition,
getProductComposition,
listProductCompositions,
saveManualProductComposition
} = require('../services/productionOrderService');
const router = express.Router();
const getRange = (query) => ({
start: query.start,
end: query.end
});
const getClientAnalyticsFilters = (query) => ({
...getRange(query),
marketplace: query.marketplace,
canal_venda: query.canal_venda,
seller: query.seller
});
router.get('/analytics/dashboard', verifyToken, async (req, res) => {
try {
res.json(await getDashboardAnalytics(getRange(req.query)));
} catch (error) {
console.error('Error fetching dashboard analytics:', error);
res.status(500).json({ error: 'Internal Server Error' });
}
});
router.get('/analytics/sellers', verifyToken, async (req, res) => {
try {
res.json(await getSellerAnalytics(getRange(req.query)));
} catch (error) {
console.error('Error fetching seller analytics:', error);
res.status(500).json({ error: 'Internal Server Error' });
}
});
router.get('/analytics/sellers/:sellerId/details', verifyToken, async (req, res) => {
try {
const details = await getSellerDetailsAnalytics(req.params.sellerId, getRange(req.query));
if (!details) {
res.status(404).json({ error: 'Seller not found' });
return;
}
res.json(details);
} catch (error) {
console.error('Error fetching seller details analytics:', error);
res.status(500).json({ error: 'Internal Server Error' });
}
});
router.get('/analytics/products', verifyToken, async (req, res) => {
try {
res.json(await getProductAnalytics(getRange(req.query)));
} catch (error) {
console.error('Error fetching product analytics:', error);
res.status(500).json({ error: 'Internal Server Error' });
}
});
router.get('/analytics/products/:productId/details', verifyToken, async (req, res) => {
try {
const details = await getProductDetailsAnalytics(req.params.productId, getRange(req.query));
if (!details) {
res.status(404).json({ error: 'Product not found' });
return;
}
res.json(details);
} catch (error) {
console.error('Error fetching product details analytics:', error);
res.status(500).json({ error: 'Internal Server Error' });
}
});
router.get('/analytics/products/:productId/composition', verifyToken, async (req, res) => {
try {
const composition = await getProductComposition(req.params.productId);
res.json({ composition });
} catch (error) {
console.error('Error fetching product composition:', error);
res.status(500).json({ error: 'Internal Server Error' });
}
});
router.put('/analytics/products/:productId/composition', verifyToken, async (req, res, next) => {
try {
res.json({ composition: await saveManualProductComposition(req.params.productId, req.body) });
} catch (error) {
next(error);
}
});
router.delete('/analytics/products/:productId/composition', verifyToken, async (req, res, next) => {
try {
res.json(await deleteManualProductComposition(req.params.productId));
} catch (error) {
next(error);
}
});
router.get('/analytics/product-compositions', verifyToken, async (req, res) => {
try {
res.json({ compositions: await listProductCompositions() });
} catch (error) {
console.error('Error exporting product compositions:', error);
res.status(500).json({ error: 'Internal Server Error' });
}
});
router.get('/analytics/clients', verifyToken, async (req, res) => {
try {
res.json(await getClientAnalytics(getClientAnalyticsFilters(req.query)));
} catch (error) {
console.error('Error fetching client analytics:', error);
res.status(500).json({ error: 'Internal Server Error' });
}
});
router.get('/analytics/clients/filters', verifyToken, async (req, res) => {
try {
res.json(await getClientFilterOptions(getRange(req.query)));
} catch (error) {
console.error('Error fetching client filter options:', error);
res.status(500).json({ error: 'Internal Server Error' });
}
});
router.get('/analytics/clients/purchase-pattern', verifyToken, async (req, res) => {
try {
res.json(await getClientPurchasePatternAnalytics());
} catch (error) {
console.error('Error fetching client purchase pattern analytics:', error);
res.status(500).json({ error: 'Internal Server Error' });
}
});
router.get('/analytics/clients/:clientToken/details', verifyToken, async (req, res) => {
try {
const details = await getClientDetailsAnalytics(req.params.clientToken, getRange(req.query));
if (!details) {
res.status(404).json({ error: 'Client not found' });
return;
}
res.json(details);
} catch (error) {
console.error('Error fetching client details analytics:', error);
res.status(500).json({ error: 'Internal Server Error' });
}
});
router.get('/analytics/rfm', verifyToken, async (req, res) => {
try {
res.json(await getRfmAnalytics(getClientAnalyticsFilters(req.query)));
} catch (error) {
console.error('Error fetching RFM analytics:', error);
res.status(500).json({ error: 'Internal Server Error' });
}
});
module.exports = router;

View File

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

View File

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

View File

@@ -1,137 +0,0 @@
const express = require('express');
const { verifyToken } = require('../auth');
const {
createCategory,
createConsumptionReference,
createProduct,
deleteCategory,
deleteConsumptionReference,
deleteProduct,
getCatalogSummary,
listCategories,
listConsumptionReferences,
listProducts,
updateCategory,
updateConsumptionReference,
updateProduct
} = require('../services/catalogService');
const { importTinyProductCompositionExport } = require('../services/productionOrderService');
const router = express.Router();
router.get('/catalog', verifyToken, async (req, res, next) => {
try {
res.json(await getCatalogSummary());
} catch (error) {
next(error);
}
});
router.get('/catalog/categories', verifyToken, async (req, res, next) => {
try {
res.json(await listCategories());
} catch (error) {
next(error);
}
});
router.post('/catalog/categories', verifyToken, async (req, res, next) => {
try {
res.status(201).json(await createCategory(req.body || {}));
} catch (error) {
next(error);
}
});
router.put('/catalog/categories/:id', verifyToken, async (req, res, next) => {
try {
res.json(await updateCategory(req.params.id, req.body || {}));
} catch (error) {
next(error);
}
});
router.delete('/catalog/categories/:id', verifyToken, async (req, res, next) => {
try {
await deleteCategory(req.params.id);
res.status(204).end();
} catch (error) {
next(error);
}
});
router.get('/catalog/products', verifyToken, async (req, res, next) => {
try {
res.json(await listProducts());
} catch (error) {
next(error);
}
});
router.post('/catalog/products', verifyToken, async (req, res, next) => {
try {
res.status(201).json(await createProduct(req.body || {}));
} catch (error) {
next(error);
}
});
router.put('/catalog/products/:id', verifyToken, async (req, res, next) => {
try {
res.json(await updateProduct(req.params.id, req.body || {}));
} catch (error) {
next(error);
}
});
router.delete('/catalog/products/:id', verifyToken, async (req, res, next) => {
try {
await deleteProduct(req.params.id);
res.status(204).end();
} catch (error) {
next(error);
}
});
router.get('/catalog/consumption-references', verifyToken, async (req, res, next) => {
try {
res.json(await listConsumptionReferences());
} catch (error) {
next(error);
}
});
router.post('/catalog/consumption-references', verifyToken, async (req, res, next) => {
try {
res.status(201).json(await createConsumptionReference(req.body || {}));
} catch (error) {
next(error);
}
});
router.put('/catalog/consumption-references/:id', verifyToken, async (req, res, next) => {
try {
res.json(await updateConsumptionReference(req.params.id, req.body || {}));
} catch (error) {
next(error);
}
});
router.post('/catalog/product-compositions/import', verifyToken, async (req, res, next) => {
try {
res.status(201).json(await importTinyProductCompositionExport(req.body || {}));
} catch (error) {
next(error);
}
});
router.delete('/catalog/consumption-references/:id', verifyToken, async (req, res, next) => {
try {
await deleteConsumptionReference(req.params.id);
res.status(204).end();
} catch (error) {
next(error);
}
});
module.exports = router;

View File

@@ -1,23 +0,0 @@
const express = require('express');
const { verifyToken } = require('../auth');
const { listCuttingSettings, saveCuttingSettings } = require('../services/cuttingSettingsService');
const router = express.Router();
router.get('/cutting-settings', verifyToken, async (req, res, next) => {
try {
res.json(await listCuttingSettings());
} catch (error) {
next(error);
}
});
router.put('/cutting-settings', verifyToken, async (req, res, next) => {
try {
res.json(await saveCuttingSettings(req.body || {}));
} catch (error) {
next(error);
}
});
module.exports = router;

View File

@@ -1,26 +0,0 @@
const express = require('express');
const { authenticateAPIKey, verifyToken } = require('../auth');
const { listOrders, upsertOrders } = require('../services/ordersService');
const router = express.Router();
router.get('/data', verifyToken, async (req, res) => {
try {
res.json(await listOrders());
} catch (error) {
console.error('Error fetching data:', error);
res.status(500).json({ error: 'Internal Server Error' });
}
});
router.post('/data', authenticateAPIKey, async (req, res) => {
res.status(201).json({ message: 'Data received, processing in background' });
const payload = Array.isArray(req.body) ? req.body : [req.body];
upsertOrders(payload).catch((error) => {
console.error('Database insert error:', error);
});
});
module.exports = router;

View File

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

View File

@@ -1,17 +0,0 @@
const express = require('express');
const { authenticateAPIKey } = require('../auth');
const { processPendingStockCampaigns } = require('../services/campaignService');
const router = express.Router();
router.post('/process-stock-campaigns', authenticateAPIKey, async (req, res) => {
try {
const summary = await processPendingStockCampaigns();
res.json(summary);
} catch (error) {
console.error('Error processing stock campaigns:', error);
res.status(500).json({ error: 'Internal Server Error' });
}
});
module.exports = router;

View File

@@ -1,107 +0,0 @@
const express = require('express');
const { verifyToken } = require('../auth');
const {
completeAuthorization,
createAuthorizationUrl,
getOlistFrontendRedirect,
getOlistRunDetails,
getOlistStatus,
requestOlistSyncStop,
startOlistSync
} = require('../services/olistService');
const { exportMissingProductionOrderDataCsv, importFinalizedProductionOrderCsv, listProductionOrderImportData } = require('../services/productionOrderCsvImportService');
const router = express.Router();
router.get('/olist/status', verifyToken, async (req, res, next) => {
try {
res.json(await getOlistStatus({
runsPage: req.query.runsPage,
runsPageSize: req.query.runsPageSize
}));
} catch (error) {
next(error);
}
});
router.post('/olist/authorization-url', verifyToken, async (req, res, next) => {
try {
res.json({ url: await createAuthorizationUrl() });
} catch (error) {
next(error);
}
});
router.post('/olist/sync', verifyToken, async (req, res, next) => {
try {
const result = await startOlistSync({
trigger: 'manual',
fullSync: req.body?.fullSync === true
});
res.status(result.accepted ? 202 : 409).json(result);
} catch (error) {
next(error);
}
});
router.post('/olist/sync/stop', verifyToken, async (req, res, next) => {
try {
res.status(202).json(await requestOlistSyncStop());
} catch (error) {
next(error);
}
});
router.get('/olist/production-order-imports', verifyToken, async (req, res, next) => {
try {
res.json(await listProductionOrderImportData({
runsPage: req.query.runsPage,
runsPageSize: req.query.runsPageSize
}));
} catch (error) {
next(error);
}
});
router.get('/olist/production-order-imports/pending-csv', verifyToken, async (req, res, next) => {
try {
res.json(await exportMissingProductionOrderDataCsv());
} catch (error) {
next(error);
}
});
router.post('/olist/production-order-imports', verifyToken, async (req, res, next) => {
try {
res.status(201).json(await importFinalizedProductionOrderCsv(req.body || {}));
} catch (error) {
next(error);
}
});
router.get('/olist/runs/:runId', verifyToken, async (req, res, next) => {
try {
res.json(await getOlistRunDetails(req.params.runId, {
eventsPage: req.query.eventsPage,
productsPage: req.query.productsPage
}));
} catch (error) {
next(error);
}
});
router.get('/olist/oauth/callback', async (req, res) => {
try {
if (req.query.error || !req.query.code || !req.query.state) {
res.redirect(getOlistFrontendRedirect('cancelled'));
return;
}
await completeAuthorization({ code: String(req.query.code), state: String(req.query.state) });
res.redirect(getOlistFrontendRedirect('connected'));
} catch (error) {
console.error('Olist OAuth callback failed:', error.message);
res.redirect(getOlistFrontendRedirect('failed'));
}
});
module.exports = router;

View File

@@ -1,130 +0,0 @@
const express = require('express');
const { authenticateAPIKey, verifyToken } = require('../auth');
const { addProductionOrderDefect, createProductionOrders, createProductionWorkstation, importTinyProductCompositionExport, listProductionOrders, listProductionPlans, listProductionWorkstations, saveProductionPlanReview, updateProductionOrderExecution, updateProductionOrderStatus, updateProductionPlan, updateProductionPlanStatus, updateProductionWorkstation, upsertTinyProductionOrderDetail } = require('../services/productionOrderService');
const router = express.Router();
router.get('/production-orders', verifyToken, async (req, res) => {
try {
res.json(await listProductionOrders(req.query || {}));
} catch (error) {
console.error('Error fetching production orders:', error);
res.status(500).json({ error: 'Internal Server Error' });
}
});
router.post('/production-orders', verifyToken, async (req, res) => {
try {
const orders = Array.isArray(req.body?.orders) ? req.body.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({ includeInactive: req.query?.includeInactive === 'true' }));
} catch (error) {
res.status(500).json({ error: 'Internal Server Error' });
}
});
router.post('/production-workstations', verifyToken, async (req, res) => {
try {
res.status(201).json(await createProductionWorkstation(req.body || {}));
} catch (error) {
res.status(error.statusCode || 500).json({ error: error.message || 'Internal Server Error' });
}
});
router.put('/production-workstations/:id', verifyToken, async (req, res) => {
try {
res.json(await updateProductionWorkstation(req.params.id, req.body || {}));
} catch (error) {
res.status(error.statusCode || 500).json({ error: error.message || 'Internal Server Error' });
}
});
router.put('/production-plans/:id', verifyToken, async (req, res) => {
try {
res.json(await updateProductionPlan(req.params.id, req.body || {}));
} catch (error) {
console.error('Error updating production plan:', error);
res.status(error.statusCode || 500).json({ error: error.message || 'Internal Server Error' });
}
});
router.patch('/production-plans/:id/status', verifyToken, async (req, res) => {
try {
res.json(await updateProductionPlanStatus(req.params.id, req.body?.status));
} catch (error) {
console.error('Error updating production plan status:', error);
res.status(error.statusCode || 500).json({ error: error.message || 'Internal Server Error' });
}
});
router.put('/production-plans/:id/review', verifyToken, async (req, res) => {
try {
res.json(await saveProductionPlanReview(req.params.id, req.body || {}));
} catch (error) {
res.status(error.statusCode || 500).json({ error: error.message || 'Internal Server Error' });
}
});
router.post('/production-orders/tiny-sync', authenticateAPIKey, async (req, res) => {
try {
res.status(201).json(await upsertTinyProductionOrderDetail(req.body || {}));
} catch (error) {
console.error('Error syncing Tiny production order:', error);
res.status(error.statusCode || 500).json({ error: error.message || 'Internal Server Error' });
}
});
router.post('/production-orders/tiny-compositions/import', authenticateAPIKey, async (req, res) => {
try {
res.status(201).json(await importTinyProductCompositionExport(req.body || {}));
} catch (error) {
console.error('Error importing Tiny product compositions:', error);
res.status(error.statusCode || 500).json({ error: error.message || 'Internal Server Error' });
}
});
router.patch('/production-orders/:id/status', verifyToken, async (req, res) => {
try {
res.json(await updateProductionOrderStatus(req.params.id, req.body?.status));
} catch (error) {
console.error('Error updating production order status:', error);
res.status(error.statusCode || 500).json({ error: error.message || 'Internal Server Error' });
}
});
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;

View File

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

View File

@@ -1,199 +0,0 @@
const express = require('express');
const { verifyToken } = require('../auth');
const {
adjustInventoryLot,
approveReceipt,
consumeLotForProduction,
createFabricPlan,
createMaterialTransformation,
createPurchaseOrder,
createReceipt,
deleteFabricPlan,
deleteReceipt,
getSupplySummary,
listFabricPlans,
listMaterialTransformations,
listKnownSuppliers,
listPurchaseOrders,
saveMaterialDetails,
updateMaterialTransformationStatus,
updatePurchaseOrderStatus,
listLots,
listMovements,
listPurchaseNeeds,
listReceipts
} = require('../services/supplyService');
const router = express.Router();
router.get('/supply', verifyToken, async (req, res, next) => {
try {
res.json(await getSupplySummary());
} catch (error) {
next(error);
}
});
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());
} catch (error) {
next(error);
}
});
router.post('/supply/receipts', verifyToken, async (req, res, next) => {
try {
res.status(201).json(await createReceipt(req.body || {}));
} catch (error) {
next(error);
}
});
router.post('/supply/receipts/:id/approve', verifyToken, async (req, res, next) => {
try {
res.json(await approveReceipt(req.params.id));
} catch (error) {
next(error);
}
});
router.delete('/supply/receipts/:id', verifyToken, async (req, res, next) => {
try {
await deleteReceipt(req.params.id);
res.status(204).end();
} catch (error) {
next(error);
}
});
router.get('/supply/lots', verifyToken, async (req, res, next) => {
try {
res.json(await listLots());
} catch (error) {
next(error);
}
});
router.post('/supply/lots/:id/inventory-adjustment', verifyToken, async (req, res, next) => {
try {
res.json(await adjustInventoryLot(req.params.id, req.body || {}));
} catch (error) {
next(error);
}
});
router.post('/supply/lots/:id/production-exit', verifyToken, async (req, res, next) => {
try {
res.json(await consumeLotForProduction(req.params.id, req.body || {}));
} catch (error) {
next(error);
}
});
router.get('/supply/movements', verifyToken, async (req, res, next) => {
try {
res.json(await listMovements());
} catch (error) {
next(error);
}
});
router.get('/supply/fabric-plans', verifyToken, async (req, res, next) => {
try {
res.json(await listFabricPlans());
} catch (error) {
next(error);
}
});
router.post('/supply/fabric-plans', verifyToken, async (req, res, next) => {
try {
res.status(201).json(await createFabricPlan(req.body || {}));
} catch (error) {
next(error);
}
});
router.delete('/supply/fabric-plans/:id', verifyToken, async (req, res, next) => {
try {
await deleteFabricPlan(req.params.id);
res.status(204).end();
} catch (error) {
next(error);
}
});
router.get('/supply/material-transformations', verifyToken, async (req, res, next) => {
try {
res.json(await listMaterialTransformations());
} catch (error) {
next(error);
}
});
router.post('/supply/material-transformations', verifyToken, async (req, res, next) => {
try {
res.status(201).json(await createMaterialTransformation(req.body || {}));
} catch (error) {
next(error);
}
});
router.patch('/supply/material-transformations/:id/status', verifyToken, async (req, res, next) => {
try {
res.json(await updateMaterialTransformationStatus(req.params.id, req.body?.status));
} catch (error) {
next(error);
}
});
router.get('/supply/purchase-needs', verifyToken, async (req, res, next) => {
try {
res.json(await listPurchaseNeeds());
} catch (error) {
next(error);
}
});
router.put('/supply/materials/:materialKey', verifyToken, async (req, res, next) => {
try {
res.json(await saveMaterialDetails(req.params.materialKey, req.body || {}));
} catch (error) {
next(error);
}
});
module.exports = router;

View File

@@ -1,61 +0,0 @@
const express = require('express');
const { 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', verifyToken, async (req, res, next) => {
try {
const users = await listUsers();
res.json({ users });
} catch (error) {
next(error);
}
});
router.post('/users', verifyToken, async (req, res, next) => {
try {
const { name, email, password } = req.body || {};
const { user, password: userPassword, generatedPassword } = await createUser({ name, email, password });
const responsePayload = {
user
};
if (generatedPassword) {
responsePayload.temporaryPassword = userPassword;
}
res.status(201).json(responsePayload);
} catch (error) {
next(error);
}
});
router.patch('/users/:id', verifyToken, async (req, res, next) => {
try {
const user = await updateUser(req.params.id, req.body || {});
res.json({ user });
} catch (error) {
next(error);
}
});
router.delete('/users/:id', verifyToken, async (req, res, next) => {
try {
await deleteUser(req.params.id);
res.status(204).send();
} catch (error) {
next(error);
}
});
module.exports = router;

View File

@@ -1,519 +0,0 @@
#!/usr/bin/env python3
"""Normalize Nexstar cutting/replenishment workbooks into JSON.
The workbook is used as an operational planning model. This importer treats it
as an input source and emits auditable tables that the app can later persist or
render, without depending on the workbook formulas at runtime.
"""
from __future__ import annotations
import argparse
import json
import posixpath
import re
import sys
import zipfile
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from typing import Any, Dict, Iterable, List, Optional, Tuple
from xml.etree import ElementTree as ET
NS = {
"main": "http://schemas.openxmlformats.org/spreadsheetml/2006/main",
"rel": "http://schemas.openxmlformats.org/officeDocument/2006/relationships",
"pkgrel": "http://schemas.openxmlformats.org/package/2006/relationships",
}
DATE_NUM_FMT_IDS = {
14,
15,
16,
17,
22,
27,
30,
36,
45,
46,
47,
50,
57,
}
CUT_SHEETS = {"BLCS", "BLMC", "BLOS", "BLPM", "BLRM"}
COLOR_WORDS = {
"amarelo",
"azul",
"azul pr.",
"azul prussia",
"bege",
"bordo",
"bordô",
"branco",
"cafe",
"café",
"cinza",
"cor??",
"grafite",
"marinho",
"marrom",
"perola",
"pérola",
"preto",
"rosa",
"rox0",
"roxo",
"royal",
"verde b.",
"verde bandeira",
"verde militar",
"vermelho",
}
@dataclass(frozen=True)
class Cell:
value: Any
formula: Optional[str] = None
has_broken_reference: bool = False
def local_path(base: str, target: str) -> str:
if target.startswith("/"):
return target.lstrip("/")
return posixpath.normpath(posixpath.join(posixpath.dirname(base), target))
def xml_root(archive: zipfile.ZipFile, path: str) -> ET.Element:
with archive.open(path) as handle:
return ET.parse(handle).getroot()
def parse_relationships(archive: zipfile.ZipFile, path: str) -> Dict[str, str]:
if path not in archive.namelist():
return {}
root = xml_root(archive, path)
relationships: Dict[str, str] = {}
for rel in root:
rel_id = rel.attrib.get("Id")
target = rel.attrib.get("Target")
if rel_id and target:
relationships[rel_id] = target
return relationships
def load_shared_strings(archive: zipfile.ZipFile) -> List[str]:
if "xl/sharedStrings.xml" not in archive.namelist():
return []
root = xml_root(archive, "xl/sharedStrings.xml")
strings: List[str] = []
for item in root.findall("main:si", NS):
parts = [node.text or "" for node in item.findall(".//main:t", NS)]
strings.append("".join(parts))
return strings
def load_date_style_ids(archive: zipfile.ZipFile) -> set[int]:
if "xl/styles.xml" not in archive.namelist():
return set()
root = xml_root(archive, "xl/styles.xml")
date_num_fmt_ids = set(DATE_NUM_FMT_IDS)
for fmt in root.findall("main:numFmts/main:numFmt", NS):
num_fmt_id = fmt.attrib.get("numFmtId")
format_code = fmt.attrib.get("formatCode", "").lower()
if not num_fmt_id:
continue
if re.search(r"(^|[^\\[])[dmy]([^\\]]|$)", format_code):
date_num_fmt_ids.add(int(num_fmt_id))
style_ids: set[int] = set()
for index, cell_format in enumerate(root.findall("main:cellXfs/main:xf", NS)):
num_fmt_id = int(cell_format.attrib.get("numFmtId", "0"))
if num_fmt_id in date_num_fmt_ids:
style_ids.add(index)
return style_ids
def excel_date(value: float) -> str:
# Excel's 1900 date system includes the leap-year bug; this origin mirrors it
# for normal post-1900 workbook dates.
date = datetime(1899, 12, 30) + timedelta(days=value)
return date.date().isoformat()
def parse_scalar(value: Optional[str], style_id: Optional[int], cell_type: Optional[str], shared_strings: List[str], date_style_ids: set[int]) -> Any:
if value is None:
return None
if cell_type == "s":
try:
return shared_strings[int(value)]
except (ValueError, IndexError):
return value
if cell_type == "b":
return value == "1"
if cell_type in {"str", "inlineStr"}:
return value
try:
number = float(value)
except ValueError:
return value
if style_id in date_style_ids:
return excel_date(number)
if number.is_integer():
return int(number)
return number
def cell_column(cell_ref: str) -> int:
letters = re.sub(r"[^A-Z]", "", cell_ref.upper())
column = 0
for letter in letters:
column = column * 26 + (ord(letter) - ord("A") + 1)
return max(1, column)
def parse_inline_string(cell_node: ET.Element) -> Optional[str]:
inline = cell_node.find("main:is", NS)
if inline is None:
return None
return "".join(node.text or "" for node in inline.findall(".//main:t", NS))
def parse_sheet_rows(
archive: zipfile.ZipFile,
sheet_path: str,
shared_strings: List[str],
date_style_ids: set[int],
) -> List[List[Cell]]:
root = xml_root(archive, sheet_path)
rows: List[List[Cell]] = []
for row in root.findall(".//main:sheetData/main:row", NS):
parsed: List[Cell] = []
last_column = 0
for cell_node in row.findall("main:c", NS):
ref = cell_node.attrib.get("r", "")
column = cell_column(ref) if ref else last_column + 1
while len(parsed) < column - 1:
parsed.append(Cell(None))
cell_type = cell_node.attrib.get("t")
style_id = int(cell_node.attrib.get("s", "-1"))
formula_node = cell_node.find("main:f", NS)
value_node = cell_node.find("main:v", NS)
formula = formula_node.text if formula_node is not None else None
if cell_type == "inlineStr":
value = parse_inline_string(cell_node)
else:
value = parse_scalar(
value_node.text if value_node is not None else None,
style_id,
cell_type,
shared_strings,
date_style_ids,
)
parsed.append(Cell(value=value, formula=formula, has_broken_reference="#REF!" in (formula or "")))
last_column = column
while parsed and parsed[-1].value is None and not parsed[-1].formula:
parsed.pop()
if parsed:
rows.append(parsed)
return rows
def workbook_rows(path: str) -> Dict[str, List[List[Cell]]]:
with zipfile.ZipFile(path) as archive:
workbook_path = "xl/workbook.xml"
workbook = xml_root(archive, workbook_path)
rels = parse_relationships(archive, "xl/_rels/workbook.xml.rels")
shared_strings = load_shared_strings(archive)
date_style_ids = load_date_style_ids(archive)
rows_by_sheet: Dict[str, List[List[Cell]]] = {}
for sheet in workbook.findall("main:sheets/main:sheet", NS):
name = sheet.attrib["name"]
rel_id = sheet.attrib.get(f"{{{NS['rel']}}}id")
target = rels.get(rel_id or "")
if not target:
continue
sheet_path = local_path(workbook_path, target)
rows_by_sheet[name] = parse_sheet_rows(archive, sheet_path, shared_strings, date_style_ids)
return rows_by_sheet
def clean_text(value: Any) -> str:
return re.sub(r"\s+", " ", str(value or "")).strip()
def number_or_zero(value: Any) -> float:
if value in (None, ""):
return 0
if isinstance(value, (int, float)):
return float(value)
normalized = str(value).strip().replace(".", "").replace(",", ".")
try:
return float(normalized)
except ValueError:
return 0
def int_if_whole(value: float) -> int | float:
return int(value) if float(value).is_integer() else value
def row_values(row: List[Cell]) -> List[Any]:
return [cell.value for cell in row]
def get(row: List[Any], index: int) -> Any:
return row[index] if index < len(row) else None
def non_empty_rows(rows: Iterable[List[Cell]]) -> Iterable[List[Any]]:
for row in rows:
values = row_values(row)
if any(value not in (None, "") for value in values):
yield values
def normalize_purchase_need(rows: List[List[Cell]]) -> List[Dict[str, Any]]:
output: List[Dict[str, Any]] = []
for values in list(non_empty_rows(rows))[1:]:
product = clean_text(get(values, 0))
sku = clean_text(get(values, 1))
if not product:
continue
output.append(
{
"product": product,
"sku": sku,
"supplier": clean_text(get(values, 2)),
"unit": clean_text(get(values, 3)),
"averageCost": number_or_zero(get(values, 4)),
"expectedIn": number_or_zero(get(values, 5)),
"expectedOut": number_or_zero(get(values, 6)),
"physicalStock": number_or_zero(get(values, 7)),
"virtualStock": number_or_zero(get(values, 8)),
"periodOut": number_or_zero(get(values, 9)),
"monthlyAverage": number_or_zero(get(values, 10)),
"coverageMonths": number_or_zero(get(values, 11)),
"suggestedPurchase": number_or_zero(get(values, 12)),
}
)
return output
def normalize_production_orders(rows: List[List[Cell]]) -> List[Dict[str, Any]]:
output: List[Dict[str, Any]] = []
for values in list(non_empty_rows(rows))[1:]:
product = clean_text(get(values, 4))
quantity = number_or_zero(get(values, 6))
if not product and not quantity:
continue
output.append(
{
"number": clean_text(get(values, 0)),
"orderReference": clean_text(get(values, 1)),
"startDate": get(values, 2),
"expectedDate": get(values, 3),
"product": product,
"status": clean_text(get(values, 5)),
"quantity": int_if_whole(quantity),
}
)
return output
def normalize_product_mapping(rows: List[List[Cell]]) -> List[Dict[str, Any]]:
output: List[Dict[str, Any]] = []
for values in list(non_empty_rows(rows))[1:]:
product = clean_text(get(values, 4))
if not product:
continue
output.append(
{
"product": product,
"purchaseNeedProduct": clean_text(get(values, 0)),
"physicalStock": number_or_zero(get(values, 1)),
"productionOrderProduct": clean_text(get(values, 2)),
"productionOrderQuantity": number_or_zero(get(values, 3)),
"totalProductionOrder": number_or_zero(get(values, 5)),
"stockPlusProductionOrder": number_or_zero(get(values, 6)),
"finishedStock": number_or_zero(get(values, 7)),
}
)
return output
def normalize_two_column_quantity(rows: List[List[Cell]], quantity_name: str) -> List[Dict[str, Any]]:
output: List[Dict[str, Any]] = []
for values in non_empty_rows(rows):
product = clean_text(get(values, 0))
if not product:
continue
output.append({"product": product, quantity_name: number_or_zero(get(values, 1))})
return output
def normalize_matrix_sheet(rows: List[List[Cell]]) -> Dict[str, Any]:
values = list(non_empty_rows(rows))
title = clean_text(get(values[0], 0)) if values else ""
total = number_or_zero(get(values[0], 2) if len(values[0]) > 2 else get(values[0], 1)) if values else 0
return {
"title": title,
"total": int_if_whole(total),
"rows": [row[:40] for row in values[:120]],
}
def is_color_label(value: str) -> bool:
normalized = value.strip().lower()
if not normalized:
return False
return normalized in COLOR_WORDS
def extract_color_labels(rows: List[List[Any]], section_index: int) -> List[str]:
candidates: List[Tuple[int, List[str]]] = []
for nearby in rows[section_index + 1 : section_index + 8]:
labels = [clean_text(value) for value in nearby]
colors = [label for label in labels if is_color_label(label)]
if len(colors) >= 2:
candidates.append((len(colors), colors))
if not candidates:
return []
return max(candidates, key=lambda item: item[0])[1][:24]
def first_number(values: List[Any]) -> float:
for value in values:
number = number_or_zero(value)
if number:
return number
return 0.0
def normalize_cut_sheet(name: str, rows: List[List[Cell]]) -> Dict[str, Any]:
values = list(non_empty_rows(rows))
sections: List[Dict[str, Any]] = []
broken_references = sum(1 for row in rows for cell in row if cell.has_broken_reference)
for index, row in enumerate(values):
labels = [clean_text(value) for value in row]
if not any(label.lower() == "total de rolos" for label in labels):
continue
title = next((label for label in labels if "corte" in label.lower()), "")
total_rolls = first_number(row)
if not total_rolls and index + 1 < len(values):
next_row = values[index + 1]
next_labels = [clean_text(value) for value in next_row]
if any(label.lower() == "total de rolos" for label in next_labels):
total_rolls = first_number(next_row)
total_need = 0.0
for position, label in enumerate(labels):
if label.lower().startswith("total necc"):
total_need = number_or_zero(get(row, position + 1))
break
color_row = extract_color_labels(values, index)
if title:
sections.append(
{
"family": name,
"title": title,
"totalRolls": int_if_whole(total_rolls),
"totalNeed": int_if_whole(total_need),
"colors": color_row[:24],
}
)
return {
"sheet": name,
"brokenFormulaReferences": broken_references,
"sections": sections,
}
def normalize_workbook(path: str) -> Dict[str, Any]:
sheets = workbook_rows(path)
normalized = {
"source": path,
"generatedAt": datetime.now(UTC).isoformat(timespec="seconds").replace("+00:00", "Z"),
"summary": {
"sheetCount": len(sheets),
"sheets": list(sheets.keys()),
},
"purchaseNeed": normalize_purchase_need(sheets.get("NECESSIDADE DE COMPRA (colar)", [])),
"productMappings": normalize_product_mapping(sheets.get("INSERIR NOVOS PRODUTOS", [])),
"productionOrders": normalize_production_orders(sheets.get("ORDEM DE PRODUÇÃO (colar)", [])),
"realPurchaseNeed": normalize_two_column_quantity(sheets.get("NECESSIDADE REAL DE COMPRA", []), "realNeed"),
"outsideItems": normalize_two_column_quantity(sheets.get("ITENS POR FORA", []), "quantity"),
"finishedStockMatrix": normalize_matrix_sheet(sheets.get("ESTOQUE PA", [])),
"stockPlusProductionOrderMatrix": normalize_matrix_sheet(sheets.get("ESTOQUE PA+ ORDEM DE PRODUÇÃO", [])),
"cutPlans": [
normalize_cut_sheet(sheet_name, rows)
for sheet_name, rows in sheets.items()
if sheet_name in CUT_SHEETS
],
}
normalized["summary"].update(
{
"purchaseNeedRows": len(normalized["purchaseNeed"]),
"productMappingRows": len(normalized["productMappings"]),
"productionOrderRows": len(normalized["productionOrders"]),
"realPurchaseNeedRows": len(normalized["realPurchaseNeed"]),
"outsideItemRows": len(normalized["outsideItems"]),
"cutPlanSections": sum(len(plan["sections"]) for plan in normalized["cutPlans"]),
"brokenFormulaReferences": sum(plan["brokenFormulaReferences"] for plan in normalized["cutPlans"]),
}
)
return normalized
def main() -> int:
parser = argparse.ArgumentParser(description="Normalize NECESSIDADE DE CORTE workbooks into JSON.")
parser.add_argument("workbook", help="Path to the .xlsx workbook")
parser.add_argument("-o", "--output", help="Optional output JSON file. Defaults to stdout.")
parser.add_argument("--pretty", action="store_true", help="Pretty-print JSON")
args = parser.parse_args()
try:
payload = normalize_workbook(args.workbook)
except Exception as error: # noqa: BLE001 - CLI should report any parse failure cleanly.
print(f"Failed to normalize workbook: {error}", file=sys.stderr)
return 1
text = json.dumps(payload, ensure_ascii=False, indent=2 if args.pretty else None)
if args.output:
with open(args.output, "w", encoding="utf-8") as handle:
handle.write(text)
handle.write("\n")
else:
print(text)
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

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

View File

@@ -1,59 +0,0 @@
const express = require('express');
const cors = require('cors');
const bodyParser = require('body-parser');
const authRoutes = require('./routes/authRoutes');
const dataRoutes = require('./routes/dataRoutes');
const stockRoutes = require('./routes/stockRoutes');
const campaignRoutes = require('./routes/campaignRoutes');
const internalRoutes = require('./routes/internalRoutes');
const analyticsRoutes = require('./routes/analyticsRoutes');
const userRoutes = require('./routes/userRoutes');
const productionOrderRoutes = require('./routes/productionOrderRoutes');
const cuttingSettingsRoutes = require('./routes/cuttingSettingsRoutes');
const catalogRoutes = require('./routes/catalogRoutes');
const supplyRoutes = require('./routes/supplyRoutes');
const olistRoutes = require('./routes/olistRoutes');
const databaseDiagnosticRoutes = require('./routes/databaseDiagnosticRoutes');
const createApp = () => {
const app = express();
app.use(cors());
app.use(bodyParser.json({ limit: '25mb' }));
app.use('/api', authRoutes);
app.use('/api', dataRoutes);
app.use('/api', stockRoutes);
app.use('/api', campaignRoutes);
app.use('/api', productionOrderRoutes);
app.use('/api', cuttingSettingsRoutes);
app.use('/api', catalogRoutes);
app.use('/api', supplyRoutes);
app.use('/api', olistRoutes);
app.use('/api', databaseDiagnosticRoutes);
app.use('/api', analyticsRoutes);
app.use('/api', userRoutes);
app.use('/api/internal', internalRoutes);
app.use((err, req, res, next) => {
if (res.headersSent) {
next(err);
return;
}
const statusCode = err.statusCode || 500;
if (statusCode >= 500) {
console.error(err);
}
res.status(statusCode).json({
error: err.message || 'Internal server error'
});
});
return app;
};
module.exports = {
createApp
};

File diff suppressed because it is too large Load Diff

View File

@@ -1,128 +0,0 @@
const applyProductDisplayAlias = (name) => {
const productName = String(name || '').trim();
return productName
.replace(/^BASE LISA CAMISETA\b/i, 'CAMISETA PREMIUM')
.replace(/^BASE LISA OVER SIZE\b/i, 'CAMISETA PREMIUM OVER SIZE')
.replace(/^BASE LISA MOLETOM CANGURU\b/i, 'MOLETOM CANGURU PREMIUM');
};
const normalizeCampaignProductName = (name) => String(name || '')
.normalize('NFD')
.replace(/\p{Diacritic}/gu, '')
.replace(/\s+/g, ' ')
.trim()
.toUpperCase();
const isCampaignEligibleProductName = (name) => {
const normalizedName = normalizeCampaignProductName(name);
if (!normalizedName) return false;
if (/^(?:\d+(?:\.\d+)?\s+)?(?:MALHA|RIBANA)\b/.test(normalizedName)) return false;
if (/\b(?:TINTA|FILME|POLIAMIDA|PO PARA DTF|ROLO DTF|FLUIDO|PRIMER|CABECA|SENSOR|FILTRO|BOMBA)\b.*\bDTF\b/.test(normalizedName)) return false;
if (/\b(?:MALHA|RIBANA|ATACADOR|ILHOS|ETIQUETA|TAG|FITA|LINHA PARA COSTURA|FIO|TECIDO|RETALHO|RESIDUO)\b/.test(normalizedName)) return false;
if (/\b(?:SALDO ESTOQUE|FRETE|SERVICO|TRANSPORTE|TECELAGEM|TINTURARIA)\b/.test(normalizedName)) return false;
if (/\b(?:PRENSA|MAQUINA|OVERLOCK|OVERLOK|GALONEIRA|PRATELEIRA|CABO FLAT|WIPPER|PRIMER|FLUIDO|SENSOR|FILTRO)\b/.test(normalizedName)) return false;
return /\b(?:DTF|CAMISETA|CAMISA|MOLETOM|CANGURU|REGATA|OVERSIZE|OVER SIZE)\b/.test(normalizedName);
};
const formatProductNameForDisplay = (name) => {
return applyProductDisplayAlias(name)
.toLocaleLowerCase('pt-BR')
.replace(/(^|[\s/-])(\p{L})/gu, (_, separator, letter) => {
return `${separator}${letter.toLocaleUpperCase('pt-BR')}`;
});
};
const formatProductList = (productNames) => {
const displayNames = productNames.map(formatProductNameForDisplay);
return displayNames.join(' • ');
};
const groupCampaignRowsByBaseProduct = (rows) => {
return rows.reduce((acc, row) => {
if (!acc[row.base_product_name]) acc[row.base_product_name] = [];
acc[row.base_product_name].push(row);
return acc;
}, {});
};
const mapCampaignProducts = (groups) => {
return Object.entries(groups)
.sort(([, aItems], [, bItems]) => {
return new Date(aItems[0].created_at).getTime() - new Date(bItems[0].created_at).getTime();
})
.map(([baseProductName, items]) => {
const sortedItems = [...items].sort((a, b) => String(a.nome).localeCompare(String(b.nome), 'pt-BR'));
return {
baseProduct: formatProductNameForDisplay(baseProductName),
total_delta: sortedItems.reduce((sum, item) => sum + Number(item.delta_estoque || 0), 0),
sizes: sortedItems.map(item => ({
id: item.produto_id,
nome: item.nome,
delta: item.delta_estoque,
saldo: item.saldo
})),
itemIds: sortedItems.map(item => item.id)
};
});
};
const buildWhatsappCampaignPayload = (products, customers) => {
const productNames = products.map(product => product.baseProduct);
const productsText = formatProductList(productNames);
const allSizes = products.flatMap(product => product.sizes);
const totalDelta = products.reduce((sum, product) => sum + product.total_delta, 0);
return {
baseProduct: productsText,
productsText,
total_delta: totalDelta,
sizes: allSizes,
products: products.map(({ itemIds, ...product }) => product),
customers
};
};
const groupCampaignRows = (rows) => {
return Object.values(rows.reduce((acc, row) => {
const key = `${row.base_product_name}:${row.status}`;
if (!acc[key]) {
acc[key] = {
key,
baseProductName: row.base_product_name,
status: row.status,
totalDelta: 0,
rowCount: 0,
attempts: 0,
lastError: null,
createdAt: row.created_at,
updatedAt: row.updated_at,
sentAt: row.sent_at,
items: []
};
}
acc[key].totalDelta += Number(row.delta_estoque || 0);
acc[key].rowCount += 1;
acc[key].attempts = Math.max(acc[key].attempts, Number(row.attempts || 0));
acc[key].lastError = row.last_error || acc[key].lastError;
acc[key].createdAt = new Date(row.created_at) < new Date(acc[key].createdAt) ? row.created_at : acc[key].createdAt;
acc[key].updatedAt = new Date(row.updated_at) > new Date(acc[key].updatedAt) ? row.updated_at : acc[key].updatedAt;
acc[key].sentAt = row.sent_at || acc[key].sentAt;
acc[key].items.push(row);
return acc;
}, {})).sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
};
module.exports = {
buildWhatsappCampaignPayload,
formatProductNameForDisplay,
formatProductList,
groupCampaignRows,
groupCampaignRowsByBaseProduct,
isCampaignEligibleProductName,
mapCampaignProducts
};

View File

@@ -1,447 +0,0 @@
const { pool } = require('../db');
const { N8N_WHATSAPP_TRIGGER_URL } = require('../config');
const {
buildWhatsappCampaignPayload,
formatProductList,
groupCampaignRows,
groupCampaignRowsByBaseProduct,
isCampaignEligibleProductName,
mapCampaignProducts
} = require('./campaignFormatter');
const TOP_BUYERS_LIMIT = 100;
const TOP_CLIENTS_DEFAULT_DAYS = 30;
const TOP_CLIENTS_DEFAULT_LIMIT = 1000;
const TOP_CLIENTS_MAX_LIMIT = 5000;
const MAX_CAMPAIGN_ATTEMPTS = 3;
const CAMPAIGN_DELTA_THRESHOLD = 100;
const SAO_PAULO_TIME_ZONE = 'America/Sao_Paulo';
const NORMALIZED_CUSTOMER_NAME_SQL = "NULLIF(LOWER(TRIM(regexp_replace(COALESCE(cliente_nome, ''), '\\s+', ' ', 'g'))), '')";
const NORMALIZED_CUSTOMER_PHONE_SQL = "NULLIF(regexp_replace(COALESCE(cliente_fone, ''), '\\D', '', 'g'), '')";
const WHATSAPP_CUSTOMER_PHONE_SQL = `
CASE
WHEN ${NORMALIZED_CUSTOMER_PHONE_SQL} LIKE '55%' THEN ${NORMALIZED_CUSTOMER_PHONE_SQL}
WHEN length(${NORMALIZED_CUSTOMER_PHONE_SQL}) IN (10, 11) THEN '55' || ${NORMALIZED_CUSTOMER_PHONE_SQL}
ELSE ${NORMALIZED_CUSTOMER_PHONE_SQL}
END
`;
const CUSTOMER_IDENTITY_CTE = `
WITH customer_phone_by_name AS (
SELECT
${NORMALIZED_CUSTOMER_NAME_SQL} as normalized_customer_name,
(ARRAY_AGG(NULLIF(cliente_fone, '') ORDER BY data_pedido_date DESC NULLS LAST, id DESC)
)[1] as canonical_phone
FROM orders
WHERE NULLIF(cliente_fone, '') IS NOT NULL
AND ${NORMALIZED_CUSTOMER_NAME_SQL} IS NOT NULL
GROUP BY normalized_customer_name
),
identity_orders AS (
SELECT
orders.*,
${NORMALIZED_CUSTOMER_NAME_SQL} as normalized_customer_name,
COALESCE(
NULLIF(orders.cliente_fone, ''),
customer_phone_by_name.canonical_phone,
'name:' || COALESCE(NULLIF(orders.cliente_nome, ''), 'Cliente Desconhecido')
) as customer_key
FROM orders
LEFT JOIN customer_phone_by_name
ON customer_phone_by_name.normalized_customer_name = ${NORMALIZED_CUSTOMER_NAME_SQL}
)
`;
const normalizeDateParam = (value) => {
if (!value) return null;
const match = String(value).trim().match(/^(\d{4})-(\d{2})-(\d{2})$/);
if (!match) return null;
const [, yearValue, monthValue, dayValue] = match;
const year = Number(yearValue);
const month = Number(monthValue);
const day = Number(dayValue);
const date = new Date(Date.UTC(year, month - 1, day));
if (
date.getUTCFullYear() !== year ||
date.getUTCMonth() !== month - 1 ||
date.getUTCDate() !== day
) {
return null;
}
return `${yearValue}-${monthValue}-${dayValue}`;
};
const parsePositiveInteger = (value, defaultValue, maxValue) => {
const parsed = Number.parseInt(value, 10);
if (!Number.isFinite(parsed) || parsed < 1) return defaultValue;
return Math.min(parsed, maxValue);
};
const getDateStringInTimeZone = (date = new Date(), timeZone = SAO_PAULO_TIME_ZONE) => {
const parts = new Intl.DateTimeFormat('en-US', {
timeZone,
year: 'numeric',
month: '2-digit',
day: '2-digit'
}).formatToParts(date);
const partMap = Object.fromEntries(parts.map(part => [part.type, part.value]));
return `${partMap.year}-${partMap.month}-${partMap.day}`;
};
const subtractDaysFromDateString = (dateString, daysToSubtract) => {
const [year, month, day] = dateString.split('-').map(Number);
const date = new Date(Date.UTC(year, month - 1, day));
date.setUTCDate(date.getUTCDate() - daysToSubtract);
return date.toISOString().slice(0, 10);
};
const getTopClientsDateRange = ({ days = TOP_CLIENTS_DEFAULT_DAYS, start, end } = {}) => {
const normalizedDays = parsePositiveInteger(days, TOP_CLIENTS_DEFAULT_DAYS, 3650);
const normalizedEnd = normalizeDateParam(end) || getDateStringInTimeZone();
const normalizedStart = normalizeDateParam(start) || subtractDaysFromDateString(normalizedEnd, normalizedDays - 1);
return {
days: normalizedDays,
start: normalizedStart,
end: normalizedEnd
};
};
const enqueueStockCampaignItem = async (client, item) => {
if (!isCampaignEligibleProductName(item.baseProductName || item.nome)) {
return false;
}
const query = `
INSERT INTO stock_campaign_queue (
base_product_name, produto_id, nome, saldo, delta_estoque
) VALUES ($1, $2, $3, $4, $5)
`;
await client.query(query, [
item.baseProductName,
item.produtoId,
item.nome,
item.saldo,
item.deltaEstoque
]);
return true;
};
const getTopBuyersAllTime = async () => {
const result = await pool.query(`
SELECT
MAX(cliente_nome) as nome,
cliente_fone as fone,
SUM(quantidade * valor_unitario) as total_gasto,
SUM(quantidade) as total_comprado
FROM orders
WHERE cliente_fone IS NOT NULL
AND cliente_fone != ''
GROUP BY cliente_fone
ORDER BY total_gasto DESC
LIMIT $1;
`, [TOP_BUYERS_LIMIT]);
return result.rows;
};
const getTopClientsForCampaign = async ({ days, limit, start, end } = {}) => {
const range = getTopClientsDateRange({ days, start, end });
const normalizedLimit = parsePositiveInteger(limit, TOP_CLIENTS_DEFAULT_LIMIT, TOP_CLIENTS_MAX_LIMIT);
const result = await pool.query(`
WITH campaign_orders AS (
SELECT
orders.*,
${WHATSAPP_CUSTOMER_PHONE_SQL} as whatsapp_phone
FROM orders
)
SELECT
(
ARRAY_AGG(COALESCE(NULLIF(cliente_nome, ''), 'Cliente Desconhecido')
ORDER BY data_pedido_date DESC NULLS LAST, id DESC)
)[1] as nome,
(
ARRAY_AGG(whatsapp_phone ORDER BY data_pedido_date DESC NULLS LAST, id DESC)
FILTER (WHERE whatsapp_phone IS NOT NULL)
)[1] as fone,
COALESCE(SUM(quantidade * valor_unitario), 0) as total_gasto,
COALESCE(SUM(quantidade), 0) as total_comprado,
COUNT(DISTINCT COALESCE(NULLIF(pedido_id, ''), data_pedido || '_' || valor_pedido::text))::int as total_pedidos,
MAX(data_pedido_date) as ultima_compra,
ARRAY_REMOVE(ARRAY_AGG(DISTINCT whatsapp_phone), NULL) as telefones
FROM campaign_orders
WHERE data_pedido_date >= $1::date
AND data_pedido_date <= $2::date
AND whatsapp_phone IS NOT NULL
-- A campaign recipient is a WhatsApp destination, so each normalized
-- phone number must produce exactly one exported customer.
GROUP BY whatsapp_phone
ORDER BY total_gasto DESC
LIMIT $3;
`, [range.start, range.end, normalizedLimit]);
const customers = result.rows.map(row => ({
nome: row.nome,
fone: row.fone,
total_gasto: Number(row.total_gasto || 0),
total_comprado: Number(row.total_comprado || 0),
total_pedidos: Number(row.total_pedidos || 0),
ultima_compra: row.ultima_compra,
telefones: Array.isArray(row.telefones) ? row.telefones : []
}));
return {
campaign: 'top_clients',
days: range.days,
start: range.start,
end: range.end,
limit: normalizedLimit,
count: customers.length,
generated_at: new Date().toISOString(),
customers
};
};
const claimReadyCampaignItems = async () => {
const client = await pool.connect();
try {
await client.query('BEGIN');
const result = await client.query(`
WITH ready_groups AS (
SELECT base_product_name
FROM stock_campaign_queue
WHERE status IN ('pending', 'failed')
AND attempts < $1
GROUP BY base_product_name
HAVING SUM(delta_estoque) >= $2
),
ready_items AS (
SELECT queue.id
FROM stock_campaign_queue queue
JOIN ready_groups ON ready_groups.base_product_name = queue.base_product_name
WHERE queue.status IN ('pending', 'failed')
AND queue.attempts < $1
ORDER BY queue.created_at ASC
FOR UPDATE OF queue SKIP LOCKED
)
UPDATE stock_campaign_queue
SET status = 'processing',
attempts = attempts + 1,
updated_at = CURRENT_TIMESTAMP,
last_error = NULL
WHERE id IN (SELECT id FROM ready_items)
RETURNING *;
`, [MAX_CAMPAIGN_ATTEMPTS, CAMPAIGN_DELTA_THRESHOLD]);
await client.query('COMMIT');
return result.rows;
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
};
const countPendingBelowThresholdGroups = async () => {
const result = await pool.query(`
SELECT COUNT(*)::int as count
FROM (
SELECT base_product_name
FROM stock_campaign_queue
WHERE status IN ('pending', 'failed')
AND attempts < $1
GROUP BY base_product_name
HAVING SUM(delta_estoque) < $2
) below_threshold_groups;
`, [MAX_CAMPAIGN_ATTEMPTS, CAMPAIGN_DELTA_THRESHOLD]);
return result.rows[0]?.count || 0;
};
const getCampaignQueueRows = async () => {
const result = await pool.query(`
SELECT *
FROM stock_campaign_queue
ORDER BY created_at DESC, id DESC
LIMIT 500;
`);
return result.rows;
};
const getCampaignQueueSummary = async () => {
const rows = (await getCampaignQueueRows())
.filter(row => isCampaignEligibleProductName(row.base_product_name || row.nome));
return {
threshold: CAMPAIGN_DELTA_THRESHOLD,
maxAttempts: MAX_CAMPAIGN_ATTEMPTS,
groups: groupCampaignRows(rows),
rows
};
};
const getCampaignPreview = async () => {
const result = await pool.query(`
SELECT *
FROM stock_campaign_queue
WHERE status IN ('pending', 'failed')
AND attempts < $1
ORDER BY created_at ASC, id ASC;
`, [MAX_CAMPAIGN_ATTEMPTS]);
const eligibleRows = result.rows.filter(row => isCampaignEligibleProductName(row.base_product_name || row.nome));
const groups = groupCampaignRowsByBaseProduct(eligibleRows);
const readyGroups = {};
const belowThresholdGroups = {};
Object.entries(groups).forEach(([baseProductName, items]) => {
const totalDelta = items.reduce((sum, item) => sum + Number(item.delta_estoque || 0), 0);
if (totalDelta >= CAMPAIGN_DELTA_THRESHOLD) {
readyGroups[baseProductName] = items;
} else {
belowThresholdGroups[baseProductName] = items;
}
});
const readyProducts = mapCampaignProducts(readyGroups).map(({ itemIds, ...product }) => product);
const belowThresholdProducts = mapCampaignProducts(belowThresholdGroups).map(({ itemIds, ...product }) => product);
const customers = await getTopBuyersAllTime();
return {
threshold: CAMPAIGN_DELTA_THRESHOLD,
readyProducts,
belowThresholdProducts,
productsText: readyProducts.length ? formatProductList(readyProducts.map(product => product.baseProduct)) : '',
customerCount: customers.length,
customersPreview: customers.slice(0, 10)
};
};
const retryCampaignItems = async ({ ids, baseProductName } = {}) => {
const params = [];
const filters = [`status IN ('failed', 'skipped')`];
if (Array.isArray(ids) && ids.length) {
params.push(ids.map(Number));
filters.push(`id = ANY($${params.length}::int[])`);
}
if (baseProductName) {
params.push(baseProductName);
filters.push(`base_product_name = $${params.length}`);
}
const result = await pool.query(`
UPDATE stock_campaign_queue
SET status = 'pending',
attempts = 0,
last_error = NULL,
sent_at = NULL,
updated_at = CURRENT_TIMESTAMP
WHERE ${filters.join(' AND ')}
RETURNING *;
`, params);
return {
retried: result.rowCount,
rows: result.rows
};
};
const updateCampaignItemsStatus = async (ids, status, errorMessage = null) => {
if (!ids.length) return;
await pool.query(`
UPDATE stock_campaign_queue
SET status = $1::varchar,
last_error = $2,
updated_at = CURRENT_TIMESTAMP,
sent_at = CASE WHEN $1 = 'sent' THEN CURRENT_TIMESTAMP ELSE sent_at END
WHERE id = ANY($3::int[]);
`, [status, errorMessage, ids]);
};
const skipIneligibleCampaignItems = async (rows) => {
const ineligibleRows = rows.filter(row => !isCampaignEligibleProductName(row.base_product_name || row.nome));
const ids = ineligibleRows.map(row => row.id);
await updateCampaignItemsStatus(ids, 'skipped', 'Produto não elegível para campanha de cliente.');
return new Set(ineligibleRows.map(row => row.base_product_name)).size;
};
const sendWhatsappCampaign = async (products, customers) => {
const response = await fetch(N8N_WHATSAPP_TRIGGER_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(buildWhatsappCampaignPayload(products, customers))
});
if (!response.ok) {
throw new Error(`WhatsApp webhook returned status ${response.status}`);
}
};
const processPendingStockCampaigns = async () => {
const rows = await claimReadyCampaignItems();
const summary = {
claimed: rows.length,
sentGroups: 0,
skippedGroups: 0,
failedGroups: 0,
pendingBelowThresholdGroups: await countPendingBelowThresholdGroups()
};
if (!rows.length) {
return summary;
}
summary.skippedGroups += await skipIneligibleCampaignItems(rows);
const eligibleRows = rows.filter(row => isCampaignEligibleProductName(row.base_product_name || row.nome));
const groups = groupCampaignRowsByBaseProduct(eligibleRows);
const products = mapCampaignProducts(groups);
const ids = products.flatMap(product => product.itemIds);
const customers = await getTopBuyersAllTime();
if (!products.length) {
return summary;
}
if (!customers.length) {
await updateCampaignItemsStatus(ids, 'skipped', 'No customers with valid phone numbers found.');
summary.skippedGroups += products.length;
return summary;
}
try {
await sendWhatsappCampaign(products, customers);
await updateCampaignItemsStatus(ids, 'sent');
summary.sentGroups = products.length;
console.log(`[Campaign Queue] Sent one campaign with ${products.length} products to ${customers.length} all-time top buyers.`);
} catch (error) {
await updateCampaignItemsStatus(ids, 'failed', error.message);
summary.failedGroups = products.length;
console.error('[Campaign Queue] Failed to send product list campaign:', error);
}
return summary;
};
module.exports = {
enqueueStockCampaignItem,
getCampaignPreview,
getCampaignQueueSummary,
getTopClientsForCampaign,
retryCampaignItems,
processPendingStockCampaigns
};

View File

@@ -1,416 +0,0 @@
const { pool } = require('../db');
const PRODUCT_TYPES = new Set(['finished_product', 'raw_material']);
const normalizeText = (value) => String(value || '').replace(/\s+/g, ' ').trim();
const normalizeNumber = (value) => {
if (value === '' || value === null || value === undefined) return null;
const number = Number(String(value).replace(',', '.'));
return Number.isFinite(number) && number > 0 ? number : null;
};
const normalizeProductType = (value) => {
const normalized = normalizeText(value);
return PRODUCT_TYPES.has(normalized) ? normalized : 'finished_product';
};
const normalizeStringArray = (value) => {
if (!Array.isArray(value)) return [];
return value.map(item => normalizeText(item)).filter(Boolean);
};
const normalizeNumericMap = (value) => {
if (!value || typeof value !== 'object' || Array.isArray(value)) return {};
return Object.entries(value).reduce((normalized, [key, rawValue]) => {
const normalizedKey = normalizeText(key).toUpperCase();
const number = normalizeNumber(rawValue);
if (normalizedKey && number) normalized[normalizedKey] = number;
return normalized;
}, {});
};
const mapCategory = (row) => ({
id: row.id,
name: row.name,
description: row.description || '',
createdAt: row.created_at,
updatedAt: row.updated_at
});
const mapProduct = (row) => ({
id: row.id,
type: row.type,
sku: row.sku,
name: row.name,
categoryId: row.category_id,
categoryName: row.category_name || '',
composition: row.composition || '',
notes: row.notes || '',
gramature: row.gramature === null ? null : Number(row.gramature),
materialYield: row.material_yield === null ? null : Number(row.material_yield),
widthCm: row.width_cm === null ? null : Number(row.width_cm),
color: row.color || '',
subcategory: row.subcategory || '',
sizes: row.sizes || [],
createdAt: row.created_at,
updatedAt: row.updated_at
});
const mapConsumptionReference = (row) => ({
id: row.id,
productId: row.product_id,
productSku: row.product_sku,
productName: row.product_name,
materialProductId: row.material_product_id,
materialSku: row.material_sku || '',
materialName: row.material_name || '',
color: row.color || '',
generalYield: row.general_yield === null ? null : Number(row.general_yield),
sizeYields: row.size_yields || {},
sizeAreas: row.size_areas || {},
gramature: row.gramature === null ? null : Number(row.gramature),
efficiencyPercent: row.efficiency_percent === null ? null : Number(row.efficiency_percent),
ribGPerPiece: row.rib_g_per_piece === null ? null : Number(row.rib_g_per_piece),
materialCostPerKg: row.material_cost_per_kg === null ? null : Number(row.material_cost_per_kg),
consumptionQuantity: row.consumption_quantity === null ? null : Number(row.consumption_quantity),
consumptionUnit: row.consumption_unit || '',
source: row.source || 'manual',
lastProductionOrderId: row.last_production_order_id === null ? null : Number(row.last_production_order_id),
createdAt: row.created_at,
updatedAt: row.updated_at
});
const listCategories = async () => {
const result = await pool.query(`
SELECT id, name, description, created_at, updated_at
FROM catalog_categories
ORDER BY name
`);
return result.rows.map(mapCategory);
};
const createCategory = async ({ name, description }) => {
const normalizedName = normalizeText(name);
if (!normalizedName) {
const error = new Error('Nome da categoria é obrigatório.');
error.statusCode = 400;
throw error;
}
const result = await pool.query(`
INSERT INTO catalog_categories (name, description, updated_at)
VALUES ($1, $2, CURRENT_TIMESTAMP)
ON CONFLICT (name) DO UPDATE
SET description = EXCLUDED.description,
updated_at = CURRENT_TIMESTAMP
RETURNING id, name, description, created_at, updated_at
`, [normalizedName, normalizeText(description) || null]);
return mapCategory(result.rows[0]);
};
const updateCategory = async (id, { name, description }) => {
const normalizedId = Number(id);
const normalizedName = normalizeText(name);
if (!normalizedId || !normalizedName) {
const error = new Error('Nome da categoria é obrigatório.');
error.statusCode = 400;
throw error;
}
const result = await pool.query(`
UPDATE catalog_categories
SET name = $1,
description = $2,
updated_at = CURRENT_TIMESTAMP
WHERE id = $3
RETURNING id, name, description, created_at, updated_at
`, [normalizedName, normalizeText(description) || null, normalizedId]);
if (!result.rows.length) {
const error = new Error('Categoria não encontrada.');
error.statusCode = 404;
throw error;
}
return mapCategory(result.rows[0]);
};
const deleteCategory = async (id) => {
await pool.query('DELETE FROM catalog_categories WHERE id = $1', [id]);
};
const listProducts = async () => {
const result = await pool.query(`
SELECT
p.id, p.type, p.sku, p.name, p.category_id, c.name AS category_name,
p.composition, p.notes, p.gramature, p.material_yield, p.width_cm,
p.color, p.subcategory, p.sizes, p.created_at, p.updated_at
FROM catalog_products p
LEFT JOIN catalog_categories c ON c.id = p.category_id
ORDER BY p.type, p.name, p.sku
`);
return result.rows.map(mapProduct);
};
const createProduct = async (payload) => {
const type = normalizeProductType(payload.type);
const sku = normalizeText(payload.sku).toUpperCase();
const name = normalizeText(payload.name);
if (!sku || !name) {
const error = new Error('SKU e nome do produto são obrigatórios.');
error.statusCode = 400;
throw error;
}
const categoryId = Number(payload.categoryId) || null;
const result = await pool.query(`
INSERT INTO catalog_products (
type, sku, name, category_id, composition, notes, gramature,
material_yield, width_cm, color, subcategory, sizes, updated_at
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, CURRENT_TIMESTAMP)
ON CONFLICT (sku) DO UPDATE
SET type = EXCLUDED.type,
name = EXCLUDED.name,
category_id = EXCLUDED.category_id,
composition = EXCLUDED.composition,
notes = EXCLUDED.notes,
gramature = EXCLUDED.gramature,
material_yield = EXCLUDED.material_yield,
width_cm = EXCLUDED.width_cm,
color = EXCLUDED.color,
subcategory = EXCLUDED.subcategory,
sizes = EXCLUDED.sizes,
updated_at = CURRENT_TIMESTAMP
RETURNING id
`, [
type,
sku,
name,
categoryId,
normalizeText(payload.composition) || null,
normalizeText(payload.notes) || null,
normalizeNumber(payload.gramature),
normalizeNumber(payload.materialYield),
normalizeNumber(payload.widthCm),
normalizeText(payload.color) || null,
normalizeText(payload.subcategory) || null,
normalizeStringArray(payload.sizes)
]);
const products = await listProducts();
return products.find(product => product.id === result.rows[0].id);
};
const updateProduct = async (id, payload) => {
const type = normalizeProductType(payload.type);
const sku = normalizeText(payload.sku).toUpperCase();
const name = normalizeText(payload.name);
if (!sku || !name) {
const error = new Error('SKU e nome do produto são obrigatórios.');
error.statusCode = 400;
throw error;
}
const result = await pool.query(`
UPDATE catalog_products
SET type = $2,
sku = $3,
name = $4,
category_id = $5,
composition = $6,
notes = $7,
gramature = $8,
material_yield = $9,
width_cm = $10,
color = $11,
subcategory = $12,
sizes = $13,
updated_at = CURRENT_TIMESTAMP
WHERE id = $1
RETURNING id;
`, [
id,
type,
sku,
name,
Number(payload.categoryId) || null,
normalizeText(payload.composition) || null,
normalizeText(payload.notes) || null,
normalizeNumber(payload.gramature),
normalizeNumber(payload.materialYield),
normalizeNumber(payload.widthCm),
normalizeText(payload.color) || null,
normalizeText(payload.subcategory) || null,
normalizeStringArray(payload.sizes)
]);
if (!result.rows.length) {
const error = new Error('Produto não encontrado.');
error.statusCode = 404;
throw error;
}
const products = await listProducts();
return products.find(product => product.id === result.rows[0].id);
};
const deleteProduct = async (id) => {
await pool.query('DELETE FROM catalog_products WHERE id = $1', [id]);
};
const listConsumptionReferences = async () => {
const result = await pool.query(`
SELECT
r.id, r.product_id, p.sku AS product_sku, p.name AS product_name,
r.material_product_id, m.sku AS material_sku, m.name AS material_name,
r.color, r.general_yield, r.size_yields, r.size_areas,
r.gramature, r.efficiency_percent, r.rib_g_per_piece,
r.material_cost_per_kg, r.consumption_quantity, r.consumption_unit,
r.source, r.last_production_order_id, r.created_at, r.updated_at
FROM consumption_references r
JOIN catalog_products p ON p.id = r.product_id
LEFT JOIN catalog_products m ON m.id = r.material_product_id
ORDER BY p.name, m.name NULLS FIRST, r.color NULLS FIRST
`);
return result.rows.map(mapConsumptionReference);
};
const createConsumptionReference = async (payload) => {
const productId = Number(payload.productId);
if (!productId) {
const error = new Error('Produto é obrigatório.');
error.statusCode = 400;
throw error;
}
const sizeYields = normalizeNumericMap(payload.sizeYields);
const sizeAreas = normalizeNumericMap(payload.sizeAreas);
const generalYield = normalizeNumber(payload.generalYield);
const calculatedYield = Object.values(sizeYields).length
? Object.values(sizeYields).reduce((total, value) => total + value, 0) / Object.values(sizeYields).length
: null;
const consumptionQuantity = normalizeNumber(payload.consumptionQuantity);
if (!generalYield && !calculatedYield && !consumptionQuantity) {
const error = new Error('Informe o rendimento ou consumo por peça.');
error.statusCode = 400;
throw error;
}
const result = await pool.query(`
INSERT INTO consumption_references (
product_id, material_product_id, color, general_yield, size_yields,
size_areas, gramature, efficiency_percent, rib_g_per_piece,
material_cost_per_kg, consumption_quantity, consumption_unit, source, updated_at
)
VALUES ($1, $2, $3, $4, $5::jsonb, $6::jsonb, $7, $8, $9, $10, $11, $12, 'manual', CURRENT_TIMESTAMP)
RETURNING id
`, [
productId,
Number(payload.materialProductId) || null,
normalizeText(payload.color) || null,
generalYield || calculatedYield,
JSON.stringify(sizeYields),
JSON.stringify(sizeAreas),
normalizeNumber(payload.gramature),
normalizeNumber(payload.efficiencyPercent),
normalizeNumber(payload.ribGPerPiece),
normalizeNumber(payload.materialCostPerKg),
consumptionQuantity,
normalizeText(payload.consumptionUnit) || null
]);
const references = await listConsumptionReferences();
return references.find(reference => reference.id === result.rows[0].id);
};
const updateConsumptionReference = async (id, payload) => {
const productId = Number(payload.productId);
if (!productId) {
const error = new Error('Produto é obrigatório.');
error.statusCode = 400;
throw error;
}
const sizeYields = normalizeNumericMap(payload.sizeYields);
const sizeAreas = normalizeNumericMap(payload.sizeAreas);
const generalYield = normalizeNumber(payload.generalYield);
const calculatedYield = Object.values(sizeYields).length
? Object.values(sizeYields).reduce((total, value) => total + value, 0) / Object.values(sizeYields).length
: null;
const consumptionQuantity = normalizeNumber(payload.consumptionQuantity);
if (!generalYield && !calculatedYield && !consumptionQuantity) {
const error = new Error('Informe o rendimento ou consumo por peça.');
error.statusCode = 400;
throw error;
}
const result = await pool.query(`
UPDATE consumption_references
SET product_id = $2,
material_product_id = $3,
color = $4,
general_yield = $5,
size_yields = $6::jsonb,
size_areas = $7::jsonb,
gramature = $8,
efficiency_percent = $9,
rib_g_per_piece = $10,
material_cost_per_kg = $11,
consumption_quantity = $12,
consumption_unit = $13,
updated_at = CURRENT_TIMESTAMP
WHERE id = $1
RETURNING id;
`, [
id,
productId,
Number(payload.materialProductId) || null,
normalizeText(payload.color) || null,
generalYield || calculatedYield,
JSON.stringify(sizeYields),
JSON.stringify(sizeAreas),
normalizeNumber(payload.gramature),
normalizeNumber(payload.efficiencyPercent),
normalizeNumber(payload.ribGPerPiece),
normalizeNumber(payload.materialCostPerKg),
consumptionQuantity,
normalizeText(payload.consumptionUnit) || null
]);
if (!result.rows.length) {
const error = new Error('Referência de consumo não encontrada.');
error.statusCode = 404;
throw error;
}
const references = await listConsumptionReferences();
return references.find(reference => reference.id === result.rows[0].id);
};
const deleteConsumptionReference = async (id) => {
await pool.query('DELETE FROM consumption_references WHERE id = $1', [id]);
};
const getCatalogSummary = async () => {
const [categories, products, consumptionReferences] = await Promise.all([
listCategories(),
listProducts(),
listConsumptionReferences()
]);
return { categories, products, consumptionReferences };
};
module.exports = {
createCategory,
createConsumptionReference,
createProduct,
deleteCategory,
deleteConsumptionReference,
deleteProduct,
getCatalogSummary,
listCategories,
listConsumptionReferences,
listProducts,
updateCategory,
updateConsumptionReference,
updateProduct
};

View File

@@ -1,157 +0,0 @@
const { pool } = require('../db');
const FAMILY_KEYS = ['BLCS', 'BLOS', 'BLMC', 'BLPM', 'BLRM'];
const FAMILY_KEY_SET = new Set(FAMILY_KEYS);
const PRODUCT_TYPE_KEYS = [
'finished_apparel',
'finished_accessory',
'raw_material',
'packaging',
'dtf_input',
'dtf_service',
'kit_bundle',
'service',
'machine_part',
'equipment',
'ignore_from_planning',
'unknown'
];
const PRODUCT_TYPE_KEY_SET = new Set(PRODUCT_TYPE_KEYS);
const normalizeFamilyKey = (value) => {
const familyKey = String(value || '').trim().toUpperCase();
return FAMILY_KEY_SET.has(familyKey) ? familyKey : '';
};
const normalizeNumber = (value) => {
const number = Number(value);
return Number.isFinite(number) && number > 0 ? number : null;
};
const normalizeText = (value) => String(value || '').replace(/\s+/g, ' ').trim();
const normalizeProductType = (value) => {
const productType = normalizeText(value);
return PRODUCT_TYPE_KEY_SET.has(productType) ? productType : '';
};
const normalizeFamilyYields = (familyYields = {}) => {
return FAMILY_KEYS.reduce((normalized, familyKey) => {
const unitsPerRoll = normalizeNumber(familyYields[familyKey]);
if (unitsPerRoll) normalized[familyKey] = unitsPerRoll;
return normalized;
}, {});
};
const normalizeProductOverrides = (productOverrides = {}) => {
return Object.entries(productOverrides).reduce((normalized, [productId, override]) => {
const normalizedProductId = normalizeText(productId);
if (!normalizedProductId || !override || typeof override !== 'object') return normalized;
const familyKey = normalizeFamilyKey(override.familyKey);
const color = normalizeText(override.color);
const size = normalizeText(override.size).toUpperCase();
const productType = normalizeProductType(override.productType);
const planningNotes = normalizeText(override.planningNotes);
if (!familyKey && !color && !size && !productType && !planningNotes) return normalized;
normalized[normalizedProductId] = {
familyKey,
color,
size,
productType,
planningNotes
};
return normalized;
}, {});
};
const listCuttingSettings = async () => {
const [familyResult, overrideResult] = await Promise.all([
pool.query(`
SELECT family_key, units_per_roll
FROM cutting_family_rules
WHERE units_per_roll IS NOT NULL AND units_per_roll > 0
ORDER BY family_key
`),
pool.query(`
SELECT product_id, family_key, color, size, product_type, planning_notes
FROM cutting_product_overrides
ORDER BY product_id
`)
]);
return {
familyYields: familyResult.rows.reduce((settings, row) => {
settings[row.family_key] = Number(row.units_per_roll);
return settings;
}, {}),
productOverrides: overrideResult.rows.reduce((settings, row) => {
settings[row.product_id] = {
familyKey: row.family_key || '',
color: row.color || '',
size: row.size || '',
productType: row.product_type || '',
planningNotes: row.planning_notes || ''
};
return settings;
}, {})
};
};
const saveCuttingSettings = async ({ familyYields = {}, productOverrides = {} }) => {
const normalizedFamilyYields = normalizeFamilyYields(familyYields);
const normalizedProductOverrides = normalizeProductOverrides(productOverrides);
const client = await pool.connect();
try {
await client.query('BEGIN');
await client.query('DELETE FROM cutting_family_rules');
for (const [familyKey, unitsPerRoll] of Object.entries(normalizedFamilyYields)) {
await client.query(`
INSERT INTO cutting_family_rules (family_key, units_per_roll, updated_at)
VALUES ($1, $2, CURRENT_TIMESTAMP)
`, [familyKey, unitsPerRoll]);
}
await client.query('DELETE FROM cutting_product_overrides');
for (const [productId, override] of Object.entries(normalizedProductOverrides)) {
await client.query(`
INSERT INTO cutting_product_overrides (product_id, family_key, color, size, product_type, planning_notes, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, CURRENT_TIMESTAMP)
`, [
productId,
override.familyKey || null,
override.color || null,
override.size || null,
override.productType || null,
override.planningNotes || null
]);
}
await client.query('COMMIT');
return {
familyYields: normalizedFamilyYields,
productOverrides: normalizedProductOverrides
};
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
};
module.exports = {
FAMILY_KEYS,
listCuttingSettings,
normalizeFamilyKey,
normalizeFamilyYields,
normalizeProductType,
normalizeProductOverrides,
saveCuttingSettings
};

View File

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

View File

@@ -1,788 +0,0 @@
const crypto = require('crypto');
const { pool } = require('../db');
const {
OLIST_CLIENT_ID,
OLIST_CLIENT_SECRET,
OLIST_FRONTEND_URL,
OLIST_REDIRECT_URI,
OLIST_SYNC_HOUR,
OLIST_SYNC_REQUEST_DELAY_MS,
OLIST_TOKEN_ENCRYPTION_KEY
} = require('../config');
const { upsertTinyProductComposition } = require('./productionOrderService');
const OLIST_API_URL = 'https://api.tiny.com.br/public-api/v3';
const OLIST_TOKEN_URL = 'https://accounts.tiny.com.br/realms/tiny/protocol/openid-connect/token';
const OLIST_AUTHORIZE_URL = 'https://accounts.tiny.com.br/realms/tiny/protocol/openid-connect/auth';
const OAUTH_STATE_TTL_MS = 10 * 60 * 1000;
const RUN_LOCK_KEY = 8_371_204;
let activeSync = null;
let activeRunId = null;
let stopRequested = false;
let scheduledSync = null;
let nextScheduledSyncAt = null;
let nextOlistRequestAt = 0;
const normalizeText = (value) => String(value || '').trim();
const stateHash = (state) => crypto.createHash('sha256').update(state).digest('hex');
const getEncryptionKey = () => {
const raw = normalizeText(OLIST_TOKEN_ENCRYPTION_KEY);
if (/^[a-f0-9]{64}$/i.test(raw)) return Buffer.from(raw, 'hex');
try {
const decoded = Buffer.from(raw, 'base64');
return decoded.length === 32 ? decoded : null;
} catch {
return null;
}
};
const isOlistConfigured = () => Boolean(
normalizeText(OLIST_CLIENT_ID)
&& normalizeText(OLIST_CLIENT_SECRET)
&& normalizeText(OLIST_REDIRECT_URI)
&& normalizeText(OLIST_FRONTEND_URL)
&& getEncryptionKey()
);
const configuredError = () => {
const error = new Error('Configure as credenciais Olist V3 e a chave de criptografia no ambiente antes de conectar.');
error.statusCode = 409;
return error;
};
const assertConfigured = () => {
if (!isOlistConfigured()) throw configuredError();
};
const encrypt = (value) => {
const key = getEncryptionKey();
if (!key) throw configuredError();
const iv = crypto.randomBytes(12);
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
const ciphertext = Buffer.concat([cipher.update(value, 'utf8'), cipher.final()]);
return {
ciphertext: Buffer.concat([ciphertext, cipher.getAuthTag()]).toString('base64'),
iv: iv.toString('base64')
};
};
const decrypt = ({ ciphertext, iv }) => {
const key = getEncryptionKey();
if (!key) throw configuredError();
const payload = Buffer.from(ciphertext, 'base64');
const authTag = payload.subarray(-16);
const encrypted = payload.subarray(0, -16);
const decipher = crypto.createDecipheriv('aes-256-gcm', key, Buffer.from(iv, 'base64'));
decipher.setAuthTag(authTag);
return Buffer.concat([decipher.update(encrypted), decipher.final()]).toString('utf8');
};
const mapRun = (row) => ({
id: row.id,
trigger: row.trigger,
mode: row.mode,
status: row.status,
startedAt: row.started_at,
completedAt: row.completed_at,
cancelRequestedAt: row.cancel_requested_at || null,
productsScanned: Number(row.products_scanned || 0),
manufacturedProducts: Number(row.manufactured_products || 0),
imported: Number(row.imported || 0),
componentCount: Number(row.component_count || 0),
referenceCount: Number(row.reference_count || 0),
skippedReferenceCount: Number(row.skipped_reference_count || 0),
failed: Number(row.failed || 0),
errorMessage: row.error_message || '',
details: row.details || []
});
const getConnection = async () => {
const result = await pool.query('SELECT * FROM olist_connections WHERE id = 1;');
return result.rows[0] || null;
};
const listSyncRuns = async ({ page = 1, pageSize = 5 } = {}) => {
const normalizedPageSize = Math.max(1, Math.min(Number(pageSize) || 5, 20));
const normalizedPage = Math.max(1, Number(page) || 1);
const offset = (normalizedPage - 1) * normalizedPageSize;
const [runsResult, countResult] = await Promise.all([
pool.query(`
SELECT * FROM olist_sync_runs
ORDER BY started_at DESC
LIMIT $1 OFFSET $2;
`, [normalizedPageSize, offset]),
pool.query('SELECT COUNT(*)::int AS count FROM olist_sync_runs;')
]);
return {
items: runsResult.rows.map(mapRun),
total: Number(countResult.rows[0]?.count || 0),
page: normalizedPage,
pageSize: normalizedPageSize
};
};
const hasScheduledSyncToday = async () => {
const result = await pool.query(`
SELECT EXISTS (
SELECT 1
FROM olist_sync_runs
WHERE trigger = 'scheduled'
AND started_at >= (date_trunc('day', CURRENT_TIMESTAMP AT TIME ZONE 'America/Sao_Paulo') AT TIME ZONE 'America/Sao_Paulo')
) AS exists;
`);
return Boolean(result.rows[0]?.exists);
};
const getTodayOlistRunAt = () => {
const parts = new Intl.DateTimeFormat('en-CA', {
timeZone: 'America/Sao_Paulo',
year: 'numeric',
month: '2-digit',
day: '2-digit'
}).formatToParts(new Date());
const value = (type) => parts.find(part => part.type === type)?.value;
return new Date(`${value('year')}-${value('month')}-${value('day')}T${String(OLIST_SYNC_HOUR).padStart(2, '0')}:00:00-03:00`);
};
const logRunEvent = async (runId, {
level = 'info',
eventType,
message,
productId = null,
sku = '',
productDescription = '',
metadata = {}
}) => {
const result = await pool.query(`
INSERT INTO olist_sync_events (
run_id, level, event_type, message, olist_product_id, sku, product_description, metadata
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb)
RETURNING *;
`, [runId, level, eventType, message, productId ? String(productId) : null, sku || null, productDescription || null, JSON.stringify(metadata)]);
return result.rows[0];
};
const isCancellationRequested = async (runId) => {
const result = await pool.query('SELECT cancel_requested_at FROM olist_sync_runs WHERE id = $1;', [runId]);
return Boolean(result.rows[0]?.cancel_requested_at);
};
const mapEvent = (row) => ({
id: Number(row.id),
level: row.level,
eventType: row.event_type,
message: row.message,
productId: row.olist_product_id ? String(row.olist_product_id) : null,
sku: row.sku || '',
productDescription: row.product_description || '',
metadata: row.metadata || {},
createdAt: row.created_at
});
const getOlistRunDetails = async (runId, { eventsPage = 1, productsPage = 1, pageSize = 20 } = {}) => {
const normalizedRunId = Number(runId);
const normalizedPageSize = Math.max(5, Math.min(Number(pageSize) || 20, 50));
const normalizePage = (page) => Math.max(1, Number(page) || 1);
const normalizedEventsPage = normalizePage(eventsPage);
const normalizedProductsPage = normalizePage(productsPage);
const runResult = await pool.query('SELECT * FROM olist_sync_runs WHERE id = $1;', [normalizedRunId]);
if (!runResult.rows[0]) {
const error = new Error('Execução Olist não encontrada.');
error.statusCode = 404;
throw error;
}
const [eventsResult, eventsCountResult, productsResult, productsCountResult] = await Promise.all([
pool.query(`
SELECT * FROM olist_sync_events
WHERE run_id = $1
ORDER BY created_at DESC, id DESC
LIMIT $2 OFFSET $3;
`, [normalizedRunId, normalizedPageSize, (normalizedEventsPage - 1) * normalizedPageSize]),
pool.query('SELECT COUNT(*)::int AS count FROM olist_sync_events WHERE run_id = $1;', [normalizedRunId]),
pool.query(`
SELECT * FROM olist_sync_events
WHERE run_id = $1 AND event_type = 'product_imported'
ORDER BY created_at DESC, id DESC
LIMIT $2 OFFSET $3;
`, [normalizedRunId, normalizedPageSize, (normalizedProductsPage - 1) * normalizedPageSize]),
pool.query(`
SELECT COUNT(*)::int AS count FROM olist_sync_events
WHERE run_id = $1 AND event_type = 'product_imported';
`, [normalizedRunId])
]);
return {
run: mapRun(runResult.rows[0]),
events: {
items: eventsResult.rows.map(mapEvent),
total: Number(eventsCountResult.rows[0].count || 0),
page: normalizedEventsPage,
pageSize: normalizedPageSize
},
affectedProducts: {
items: productsResult.rows.map(mapEvent),
total: Number(productsCountResult.rows[0].count || 0),
page: normalizedProductsPage,
pageSize: normalizedPageSize
}
};
};
const getCatalogSyncState = async () => {
const result = await pool.query('SELECT * FROM olist_sync_state WHERE id = 1;');
return result.rows[0] || null;
};
const getProductCacheProgress = async () => {
const result = await pool.query(`
SELECT
COUNT(*)::int AS total,
COUNT(*) FILTER (WHERE structure_checked_at IS NOT NULL)::int AS checked,
COUNT(*) FILTER (WHERE structure_checked_at IS NULL)::int AS pending,
COUNT(*) FILTER (WHERE is_manufactured IS TRUE)::int AS manufactured
FROM olist_product_cache;
`);
const row = result.rows[0];
return {
total: Number(row.total || 0),
checked: Number(row.checked || 0),
pending: Number(row.pending || 0),
manufactured: Number(row.manufactured || 0)
};
};
const markInterruptedOlistRuns = async () => {
const result = await pool.query(`
UPDATE olist_sync_runs
SET status = 'failed',
completed_at = CURRENT_TIMESTAMP,
error_message = COALESCE(error_message, 'Sincronização interrompida pelo reinício do servidor.')
WHERE status = 'running';
`);
return result.rowCount || 0;
};
const getOlistStatus = async ({ runsPage = 1, runsPageSize = 5 } = {}) => {
const [connection, syncRuns, productCache, latestRunResult, completedRunResult] = await Promise.all([
getConnection(),
listSyncRuns({ page: runsPage, pageSize: runsPageSize }),
getProductCacheProgress(),
pool.query('SELECT * FROM olist_sync_runs ORDER BY started_at DESC LIMIT 1;'),
pool.query("SELECT EXISTS (SELECT 1 FROM olist_sync_runs WHERE status = 'completed') AS exists;")
]);
return {
configured: isOlistConfigured(),
connected: Boolean(connection),
tokenExpiresAt: connection?.token_expires_at || null,
syncInProgress: Boolean(activeSync),
nextScheduledSyncAt,
productCache,
latestRun: latestRunResult.rows[0] ? mapRun(latestRunResult.rows[0]) : null,
hasCompletedRun: Boolean(completedRunResult.rows[0]?.exists),
runs: syncRuns.items,
runsTotal: syncRuns.total,
runsPage: syncRuns.page,
runsPageSize: syncRuns.pageSize
};
};
const createAuthorizationUrl = async () => {
assertConfigured();
const state = crypto.randomBytes(32).toString('base64url');
await pool.query('DELETE FROM olist_oauth_states WHERE expires_at <= CURRENT_TIMESTAMP;');
await pool.query(`
INSERT INTO olist_oauth_states (state_hash, expires_at)
VALUES ($1, $2);
`, [stateHash(state), new Date(Date.now() + OAUTH_STATE_TTL_MS)]);
const params = new URLSearchParams({
client_id: OLIST_CLIENT_ID,
redirect_uri: OLIST_REDIRECT_URI,
scope: 'openid offline_access',
response_type: 'code',
state
});
return `${OLIST_AUTHORIZE_URL}?${params.toString()}`;
};
const exchangeToken = async (params) => {
const response = await fetch(OLIST_TOKEN_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
client_id: OLIST_CLIENT_ID,
client_secret: OLIST_CLIENT_SECRET,
...params
})
});
const data = await response.json().catch(() => null);
if (!response.ok || !data?.access_token) {
const error = new Error(data?.error_description || data?.error || 'Não foi possível autorizar a conexão com a Olist.');
error.statusCode = 502;
throw error;
}
return data;
};
const persistTokens = async ({ accessToken, refreshToken, expiresIn, existingConnection }) => {
const access = encrypt(accessToken);
const refresh = refreshToken ? encrypt(refreshToken) : {
ciphertext: existingConnection.refresh_token_ciphertext,
iv: existingConnection.refresh_token_iv
};
const expiresAt = new Date(Date.now() + Math.max(Number(expiresIn) || 14_400, 60) * 1000);
await pool.query(`
INSERT INTO olist_connections (
id, access_token_ciphertext, access_token_iv, refresh_token_ciphertext,
refresh_token_iv, token_expires_at, connected_at, updated_at
)
VALUES (1, $1, $2, $3, $4, $5, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
ON CONFLICT (id) DO UPDATE
SET access_token_ciphertext = EXCLUDED.access_token_ciphertext,
access_token_iv = EXCLUDED.access_token_iv,
refresh_token_ciphertext = EXCLUDED.refresh_token_ciphertext,
refresh_token_iv = EXCLUDED.refresh_token_iv,
token_expires_at = EXCLUDED.token_expires_at,
updated_at = CURRENT_TIMESTAMP;
`, [access.ciphertext, access.iv, refresh.ciphertext, refresh.iv, expiresAt]);
};
const completeAuthorization = async ({ code, state }) => {
assertConfigured();
const result = await pool.query(`
DELETE FROM olist_oauth_states
WHERE state_hash = $1 AND expires_at > CURRENT_TIMESTAMP
RETURNING state_hash;
`, [stateHash(state)]);
if (!result.rows.length) {
const error = new Error('A autorização da Olist expirou ou é inválida. Tente conectar novamente.');
error.statusCode = 400;
throw error;
}
const token = await exchangeToken({
grant_type: 'authorization_code',
redirect_uri: OLIST_REDIRECT_URI,
code
});
if (!token.refresh_token) {
const error = new Error('A Olist não retornou um refresh token. Verifique as permissões do aplicativo.');
error.statusCode = 502;
throw error;
}
await persistTokens({ accessToken: token.access_token, refreshToken: token.refresh_token, expiresIn: token.expires_in, existingConnection: {} });
};
const getAccessToken = async (forceRefresh = false) => {
assertConfigured();
const connection = await getConnection();
if (!connection) {
const error = new Error('Conecte a conta Olist antes de sincronizar.');
error.statusCode = 409;
throw error;
}
if (!forceRefresh && new Date(connection.token_expires_at).getTime() > Date.now() + 60_000) {
return decrypt({ ciphertext: connection.access_token_ciphertext, iv: connection.access_token_iv });
}
const token = await exchangeToken({
grant_type: 'refresh_token',
refresh_token: decrypt({ ciphertext: connection.refresh_token_ciphertext, iv: connection.refresh_token_iv })
});
await persistTokens({ accessToken: token.access_token, refreshToken: token.refresh_token, expiresIn: token.expires_in, existingConnection: connection });
return token.access_token;
};
const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));
const waitForOlistRequestSlot = async () => {
const now = Date.now();
const waitMs = Math.max(0, nextOlistRequestAt - now);
if (waitMs) await sleep(waitMs);
nextOlistRequestAt = Date.now() + OLIST_SYNC_REQUEST_DELAY_MS;
};
const olistRequest = async (path, retry = true) => {
await waitForOlistRequestSlot();
const response = await fetch(`${OLIST_API_URL}${path}`, {
headers: { Authorization: `Bearer ${await getAccessToken(!retry)}` }
});
if (response.status === 401 && retry) return olistRequest(path, false);
const data = await response.json().catch(() => null);
if (!response.ok) {
const error = new Error(data?.mensagem || `A Olist retornou HTTP ${response.status}.`);
error.statusCode = response.status;
throw error;
}
return data;
};
const formatOlistDate = (value) => {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return '';
return date.toISOString().slice(0, 19).replace('T', ' ');
};
const listOlistProducts = async (changedAfter = null, shouldStop = async () => false) => {
const products = [];
let offset = 0;
do {
if (await shouldStop()) return { products, cancelled: true };
const params = new URLSearchParams({ limit: '100', offset: String(offset) });
if (changedAfter) params.set('dataAlteracao', formatOlistDate(changedAfter));
const page = await olistRequest(`/produtos?${params}`);
products.push(...(Array.isArray(page?.itens) ? page.itens : []));
offset += page?.paginacao?.limit || 100;
if (offset >= Number(page?.paginacao?.total || 0)) break;
} while (true);
return { products, cancelled: false };
};
const cacheOlistProducts = async (products) => {
for (const product of products) {
if (!product?.id || !normalizeText(product.descricao)) continue;
await pool.query(`
INSERT INTO olist_product_cache (
olist_product_id, sku, description, unit, last_changed_at, updated_at
)
VALUES ($1, $2, $3, $4, $5, CURRENT_TIMESTAMP)
ON CONFLICT (olist_product_id) DO UPDATE
SET sku = EXCLUDED.sku,
description = EXCLUDED.description,
unit = EXCLUDED.unit,
structure_checked_at = CASE
WHEN olist_product_cache.last_changed_at IS DISTINCT FROM EXCLUDED.last_changed_at
THEN NULL
ELSE olist_product_cache.structure_checked_at
END,
last_changed_at = EXCLUDED.last_changed_at,
updated_at = CURRENT_TIMESTAMP;
`, [
String(product.id),
normalizeText(product.sku) || null,
normalizeText(product.descricao),
normalizeText(product.unidade) || 'UN',
product.dataAlteracao || product.dataCriacao || null
]);
}
};
const updateCatalogCheckpoint = async () => {
await pool.query(`
INSERT INTO olist_sync_state (id, last_catalog_sync_at, updated_at)
VALUES (1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
ON CONFLICT (id) DO UPDATE
SET last_catalog_sync_at = EXCLUDED.last_catalog_sync_at,
updated_at = CURRENT_TIMESTAMP;
`);
};
const listStructureCandidates = async () => {
const result = await pool.query(`
SELECT olist_product_id, sku, description, unit
FROM olist_product_cache
WHERE structure_checked_at IS NULL
ORDER BY olist_product_id;
`);
return result.rows.map(row => ({
id: String(row.olist_product_id),
sku: row.sku || '',
descricao: row.description,
unidade: row.unit || 'UN'
}));
};
const getCachedUnitById = async () => {
const result = await pool.query('SELECT olist_product_id, unit FROM olist_product_cache;');
return new Map(result.rows.map(row => [String(row.olist_product_id), row.unit || 'UN']));
};
const markStructureChecked = async (productId, isManufactured) => {
await pool.query(`
UPDATE olist_product_cache
SET structure_checked_at = CURRENT_TIMESTAMP,
is_manufactured = $2,
updated_at = CURRENT_TIMESTAMP
WHERE olist_product_id = $1;
`, [String(productId), isManufactured]);
};
const createRun = async ({ trigger, mode }) => {
const result = await pool.query(`
INSERT INTO olist_sync_runs (trigger, mode, status)
VALUES ($1, $2, 'running')
RETURNING *;
`, [trigger, mode]);
return mapRun(result.rows[0]);
};
const updateRunProgress = async (id, summary) => {
const result = await pool.query(`
UPDATE olist_sync_runs
SET products_scanned = $2,
manufactured_products = $3,
imported = $4,
component_count = $5,
reference_count = $6,
skipped_reference_count = $7,
failed = $8,
details = $9::jsonb
WHERE id = $1
RETURNING *;
`, [id, summary.productsScanned, summary.manufacturedProducts, summary.imported, summary.componentCount, summary.referenceCount, summary.skippedReferenceCount, summary.failed, JSON.stringify(summary.details.slice(0, 100))]);
return mapRun(result.rows[0]);
};
const finishRun = async (id, summary, { status = 'completed', error = null } = {}) => {
const result = await pool.query(`
UPDATE olist_sync_runs
SET status = $2,
completed_at = CURRENT_TIMESTAMP,
products_scanned = $3,
manufactured_products = $4,
imported = $5,
component_count = $6,
reference_count = $7,
skipped_reference_count = $8,
failed = $9,
error_message = $10,
details = $11::jsonb
WHERE id = $1
RETURNING *;
`, [id, status, summary.productsScanned, summary.manufacturedProducts, summary.imported, summary.componentCount, summary.referenceCount, summary.skippedReferenceCount, summary.failed, error?.message || null, JSON.stringify(summary.details.slice(0, 100))]);
return mapRun(result.rows[0]);
};
const runOlistSync = async ({ trigger = 'manual', fullSync = false } = {}) => {
assertConfigured();
if (activeSync) return activeSync;
activeSync = (async () => {
const [checkpoint, cacheProgress] = await Promise.all([getCatalogSyncState(), getProductCacheProgress()]);
const mode = fullSync || !checkpoint || cacheProgress.pending > 0 ? 'full' : 'incremental';
const run = await createRun({ trigger, mode });
activeRunId = run.id;
const summary = { productsScanned: 0, manufacturedProducts: 0, imported: 0, componentCount: 0, referenceCount: 0, skippedReferenceCount: 0, failed: 0, details: [] };
let lockClient;
try {
await logRunEvent(run.id, { eventType: 'started', message: 'Sincronização iniciada.' });
lockClient = await pool.connect();
const lock = await lockClient.query('SELECT pg_try_advisory_lock($1) AS locked;', [RUN_LOCK_KEY]);
if (!lock.rows[0]?.locked) {
const error = new Error('Outra sincronização Olist já está em andamento.');
error.statusCode = 409;
throw error;
}
const resumePendingFullScan = mode === 'full' && cacheProgress.total > 0 && cacheProgress.pending > 0;
if (!resumePendingFullScan) {
const catalogResult = await listOlistProducts(
mode === 'incremental' ? checkpoint.last_catalog_sync_at : null,
async () => stopRequested || isCancellationRequested(run.id)
);
if (catalogResult.cancelled) {
await logRunEvent(run.id, { eventType: 'cancelled', message: 'Sincronização interrompida a pedido do usuário antes de concluir a leitura do catálogo.' });
return await finishRun(run.id, summary, { status: 'cancelled' });
}
const changedProducts = catalogResult.products;
await cacheOlistProducts(changedProducts);
await updateCatalogCheckpoint();
await logRunEvent(run.id, {
eventType: 'catalog_loaded',
message: `${changedProducts.length.toLocaleString('pt-BR')} produtos recebidos da Olist.`,
metadata: { productCount: changedProducts.length, mode }
});
} else {
await logRunEvent(run.id, {
eventType: 'resumed',
message: 'Retomando estruturas pendentes da sincronização anterior.',
metadata: { pendingStructures: cacheProgress.pending }
});
}
const [candidates, unitById] = await Promise.all([listStructureCandidates(), getCachedUnitById()]);
for (const product of candidates) {
if (stopRequested || await isCancellationRequested(run.id)) {
await logRunEvent(run.id, { eventType: 'cancelled', message: 'Sincronização interrompida a pedido do usuário.' });
return await finishRun(run.id, summary, { status: 'cancelled' });
}
summary.productsScanned += 1;
try {
const structure = await olistRequest(`/produtos/${product.id}/fabricado`);
const components = Array.isArray(structure?.produtos) ? structure.produtos : [];
if (!components.length) {
await markStructureChecked(product.id, false);
await logRunEvent(run.id, {
eventType: 'product_skipped',
message: `Produto sem composição fabricada: ${product.descricao}.`,
productId: product.id,
sku: product.sku,
productDescription: product.descricao
});
} else {
const result = await upsertTinyProductComposition({
tinyId: String(product.id),
productSku: product.sku,
productDescription: product.descricao,
unit: product.unidade || 'UN',
rawPayload: { product, structure }
}, components.map(component => ({
componentTinyId: String(component?.produto?.id || ''),
componentSku: component?.produto?.sku || '',
componentName: component?.produto?.descricao || '',
quantityPerUnit: component?.quantidade,
unit: unitById.get(String(component?.produto?.id)) || 'UN'
})));
summary.manufacturedProducts += 1;
summary.imported += 1;
summary.componentCount += result.componentCount || 0;
summary.referenceCount += result.referenceCount || 0;
summary.skippedReferenceCount += result.skippedReferenceCount || 0;
await markStructureChecked(product.id, true);
await logRunEvent(run.id, {
eventType: 'product_imported',
message: `Composição importada: ${product.descricao}.`,
productId: product.id,
sku: product.sku,
productDescription: product.descricao,
metadata: {
componentCount: result.componentCount || 0,
referenceCount: result.referenceCount || 0
}
});
}
} catch (error) {
if (error.statusCode === 400 || error.statusCode === 404) {
await markStructureChecked(product.id, false);
await logRunEvent(run.id, {
eventType: 'product_skipped',
message: `Produto sem composição fabricada: ${product.descricao}.`,
productId: product.id,
sku: product.sku,
productDescription: product.descricao
});
} else {
summary.failed += 1;
summary.details.push({ productId: product.id, sku: product.sku || '', error: error.message });
await logRunEvent(run.id, {
level: 'error',
eventType: 'product_failed',
message: `Falha ao consultar a composição: ${error.message}`,
productId: product.id,
sku: product.sku,
productDescription: product.descricao,
metadata: { error: error.message }
});
}
}
await updateRunProgress(run.id, summary);
if (summary.productsScanned % 25 === 0) {
await logRunEvent(run.id, {
eventType: 'progress',
message: `${summary.productsScanned.toLocaleString('pt-BR')} estruturas verificadas; ${summary.imported.toLocaleString('pt-BR')} composições importadas.`,
metadata: { productsScanned: summary.productsScanned, imported: summary.imported, failed: summary.failed }
});
}
}
await logRunEvent(run.id, { eventType: 'completed', message: 'Sincronização concluída.', metadata: summary });
return await finishRun(run.id, summary);
} catch (error) {
await logRunEvent(run.id, { level: 'error', eventType: 'failed', message: error.message, metadata: { error: error.message } }).catch(() => {});
await finishRun(run.id, summary, { status: 'failed', error });
throw error;
} finally {
if (lockClient) {
await lockClient.query('SELECT pg_advisory_unlock($1);', [RUN_LOCK_KEY]).catch(() => {});
lockClient.release();
}
}
})().finally(() => {
activeSync = null;
activeRunId = null;
stopRequested = false;
});
return activeSync;
};
const startOlistSync = async (options) => {
assertConfigured();
if (!await getConnection()) {
const error = new Error('Conecte a conta Olist antes de sincronizar.');
error.statusCode = 409;
throw error;
}
if (activeSync) return { accepted: false, reason: 'running' };
runOlistSync(options).catch(error => console.error('Olist sync failed:', error.message));
return { accepted: true };
};
const requestOlistSyncStop = async () => {
if (!activeSync) {
const error = new Error('Não há uma sincronização Olist em andamento.');
error.statusCode = 409;
throw error;
}
stopRequested = true;
if (!activeRunId) return { accepted: true, initializing: true };
const result = await pool.query(`
UPDATE olist_sync_runs
SET cancel_requested_at = COALESCE(cancel_requested_at, CURRENT_TIMESTAMP)
WHERE id = $1
RETURNING *;
`, [activeRunId]);
if (result.rows[0]) {
await logRunEvent(activeRunId, { eventType: 'cancel_requested', message: 'Parada solicitada pelo usuário.' });
}
return { accepted: true, initializing: false };
};
const scheduleOlistSync = async () => {
if (scheduledSync) clearTimeout(scheduledSync);
let scheduledRunAt = getTodayOlistRunAt();
let delay = Math.max(0, scheduledRunAt.getTime() - Date.now());
try {
if (delay === 0 && await hasScheduledSyncToday()) {
scheduledRunAt.setUTCDate(scheduledRunAt.getUTCDate() + 1);
delay = scheduledRunAt.getTime() - Date.now();
}
} catch (error) {
console.error('Could not calculate the next daily Olist sync:', error.message);
}
nextScheduledSyncAt = new Date(Date.now() + delay).toISOString();
scheduledSync = setTimeout(async () => {
scheduledSync = null;
nextScheduledSyncAt = null;
try {
assertConfigured();
if (!await getConnection()) {
console.error('Scheduled Olist sync skipped: no connected Olist account.');
return;
}
await runOlistSync({ trigger: 'scheduled' });
} catch (error) {
console.error('Scheduled Olist sync failed:', error.message);
} finally {
await scheduleOlistSync();
}
}, delay);
return nextScheduledSyncAt;
};
const getOlistFrontendRedirect = (outcome) => `${OLIST_FRONTEND_URL.replace(/\/$/, '')}/#/admin/olist?olist=${outcome}`;
module.exports = {
completeAuthorization,
createAuthorizationUrl,
getOlistFrontendRedirect,
getOlistRunDetails,
getOlistStatus,
isOlistConfigured,
markInterruptedOlistRuns,
scheduleOlistSync,
requestOlistSyncStop,
startOlistSync
};

View File

@@ -1,55 +0,0 @@
const { pool } = require('../db');
const { formatOrderRow, normalizeOrderPayload } = require('../mappers/orderMapper');
const listOrders = async () => {
const result = await pool.query('SELECT * FROM orders ORDER BY id DESC');
return result.rows.map(formatOrderRow);
};
const upsertOrders = async (payload) => {
const client = await pool.connect();
try {
await client.query('BEGIN');
const insertQuery = `
INSERT INTO orders (
cliente_nome, data_pedido, data_pedido_date, valor_pedido,
produto_id, produto_descricao, quantidade, valor_unitario, pedido_id, cliente_fone,
cliente_nome_fantasia, id_vendedor, nome_vendedor, marketplace, canal_venda, numero_ecommerce
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16)
ON CONFLICT (pedido_id, produto_id) DO UPDATE SET
cliente_nome = EXCLUDED.cliente_nome,
data_pedido = EXCLUDED.data_pedido,
data_pedido_date = EXCLUDED.data_pedido_date,
valor_pedido = EXCLUDED.valor_pedido,
produto_descricao = EXCLUDED.produto_descricao,
quantidade = EXCLUDED.quantidade,
valor_unitario = EXCLUDED.valor_unitario,
cliente_fone = COALESCE(NULLIF(EXCLUDED.cliente_fone, ''), orders.cliente_fone),
cliente_nome_fantasia = COALESCE(NULLIF(EXCLUDED.cliente_nome_fantasia, ''), orders.cliente_nome_fantasia),
id_vendedor = COALESCE(NULLIF(EXCLUDED.id_vendedor, ''), orders.id_vendedor),
nome_vendedor = COALESCE(NULLIF(EXCLUDED.nome_vendedor, ''), orders.nome_vendedor),
marketplace = COALESCE(NULLIF(EXCLUDED.marketplace, ''), orders.marketplace),
canal_venda = COALESCE(NULLIF(EXCLUDED.canal_venda, ''), orders.canal_venda),
numero_ecommerce = COALESCE(NULLIF(EXCLUDED.numero_ecommerce, ''), orders.numero_ecommerce),
created_at = CURRENT_TIMESTAMP
`;
for (const item of payload) {
await client.query(insertQuery, normalizeOrderPayload(item));
}
await client.query('COMMIT');
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
};
module.exports = {
listOrders,
upsertOrders
};

View File

@@ -1,369 +0,0 @@
const { pool } = require('../db');
const FAMILY_KEYS = new Set(['BLCS', 'BLOS', 'BLMC', 'BLPM', 'BLRM']);
const REQUIRED_HEADERS = ['id_op', 'numero_op', 'sku_produto', 'descricao_produto', 'quantidade_produzida', 'data_emissao', 'data_conclusao', 'situacao', 'observacoes'];
const normalizeText = (value) => String(value || '').replace(/\s+/g, ' ').trim();
const normalizeNumber = (value) => {
const normalized = String(value || '').trim().replace(/\s/g, '');
if (!normalized) return null;
const decimal = normalized.includes(',')
? normalized.replace(/\./g, '').replace(',', '.')
: normalized;
const number = Number(decimal);
return Number.isFinite(number) && number > 0 ? number : null;
};
const normalizeDate = (value) => {
const raw = String(value || '').trim();
const iso = raw.match(/^(\d{4})-(\d{2})-(\d{2})/);
if (iso) return `${iso[1]}-${iso[2]}-${iso[3]}`;
const br = raw.match(/^(\d{1,2})\/(\d{1,2})\/(\d{4})/);
return br ? `${br[3]}-${br[2].padStart(2, '0')}-${br[1].padStart(2, '0')}` : null;
};
const normalizeHeader = (header) => String(header || '')
.replace(/^\uFEFF/, '')
.normalize('NFD')
.replace(/\p{Diacritic}/gu, '')
.trim()
.toLowerCase()
.replace(/\s+/g, '_');
const csvCell = (value) => {
const normalized = value === null || value === undefined ? '' : String(value);
return /[",\r\n]/.test(normalized) ? `"${normalized.replace(/"/g, '""')}"` : normalized;
};
const parseCsv = (csv) => {
const rows = [];
let row = [];
let field = '';
let quoted = false;
for (let index = 0; index < csv.length; index += 1) {
const character = csv[index];
const next = csv[index + 1];
if (character === '"') {
if (quoted && next === '"') {
field += '"';
index += 1;
} else {
quoted = !quoted;
}
} else if (character === ',' && !quoted) {
row.push(field);
field = '';
} else if ((character === '\n' || character === '\r') && !quoted) {
if (character === '\r' && next === '\n') index += 1;
row.push(field);
if (row.some(value => value.length > 0)) rows.push(row);
row = [];
field = '';
} else {
field += character;
}
}
row.push(field);
if (row.some(value => value.length > 0)) rows.push(row);
if (quoted) throw new Error('CSV possui aspas não finalizadas.');
return rows;
};
const extractObservationFields = (notes) => {
const raw = String(notes || '');
const lineValue = (pattern) => raw.match(pattern)?.[1]?.trim() || '';
const supplier = lineValue(/(?:^|\n)\s*FORNECEDOR\s*:\s*([^\r\n]+)/i);
const lotCode = lineValue(/(?:^|\n)\s*(?:-\s*)?LOTE\s*:?[\t ]*([^\r\n]+)/i);
const yieldMatch = raw.match(/RENDIMENTO\s*:\s*([\d.,]+)(?:\s*[-–]\s*([\d.,]+)\s*KG)?/i);
const yieldPiecesPerKg = normalizeNumber(yieldMatch?.[1]);
const bobbinMatch = raw.match(/(?:QUANTIDADE\s+DE\s+ROLOS|ROLOS|BOBINAS)\s*:\s*(\d+(?:[.,]\d+)?)(?:\s*\(\s*X\s*(\d+(?:[.,]\d+)?)\s*\))?(?:\s*[-–]\s*([\d.,]+)\s*KG)?/i);
const rollBase = normalizeNumber(bobbinMatch?.[1]);
const rollMultiplier = normalizeNumber(bobbinMatch?.[2]) || 1;
const explicitRolls = rollBase ? rollBase * rollMultiplier : null;
const fabricKg = normalizeNumber(raw.match(/(?:QUILOS?\s+DE\s+MALHA|QUILOS?\s+MALHA)\s*:\s*([\d.,]+)(?:\s*KG)?/i)?.[1])
|| normalizeNumber(bobbinMatch?.[3])
|| normalizeNumber(yieldMatch?.[2])
|| normalizeNumber(raw.match(/(?:^|\n)\s*KG\s*:\s*([\d.,]+)(?:\s*KG)?/i)?.[1]);
const ribKg = normalizeNumber(raw.match(/(?:QUILOS?\s+DE\s+(?:GOLA|RIBANA|VIEZ(?:\s*\([^)]*\))?)|RIBANA)\s*:\s*([\d.,]+)(?:\s*KG)?/i)?.[1]);
return { supplier, lotCode, yieldPiecesPerKg, rollQuantity: explicitRolls, fabricKg, ribKg };
};
const parseFinalizedProductionOrderCsv = (csv) => {
if (typeof csv !== 'string' || !csv.trim()) {
const error = new Error('Selecione um arquivo CSV de OPs finalizadas.');
error.statusCode = 400;
throw error;
}
const rows = parseCsv(csv);
if (rows.length < 2) {
const error = new Error('O CSV não possui OPs para importar.');
error.statusCode = 400;
throw error;
}
const headers = rows[0].map(normalizeHeader);
const missingHeaders = REQUIRED_HEADERS.filter(header => !headers.includes(header));
if (missingHeaders.length) {
const error = new Error(`CSV sem colunas obrigatórias: ${missingHeaders.join(', ')}.`);
error.statusCode = 400;
throw error;
}
const headerIndex = Object.fromEntries(headers.map((header, index) => [header, index]));
const records = [];
const issues = [];
rows.slice(1).forEach((row, rowOffset) => {
const line = rowOffset + 2;
const value = (header) => row[headerIndex[header]] || '';
const tinyId = normalizeText(value('id_op'));
const number = normalizeText(value('numero_op'));
const productSku = normalizeText(value('sku_produto')).toUpperCase();
const productDescription = normalizeText(value('descricao_produto'));
const quantity = normalizeNumber(value('quantidade_produzida'));
const status = normalizeText(value('situacao')).toLowerCase();
const notes = String(value('observacoes') || '').trim();
if (!tinyId || !number || !productSku || !productDescription || !quantity) {
issues.push({ line, message: 'OP sem identificação, SKU, produto ou quantidade válida.' });
return;
}
if (!/^finalizad[ao]$/.test(status)) {
issues.push({ line, number, message: 'Ignorada: a OP não está Finalizada.' });
return;
}
const familyKey = productSku.split('.')[0];
const extracted = extractObservationFields(notes);
const supplier = normalizeText(value('fornecedor')) || extracted.supplier;
const lotCode = normalizeText(value('lote')) || extracted.lotCode;
const rollQuantity = normalizeNumber(value('quantidade_rolos')) || extracted.rollQuantity;
const fabricKg = normalizeNumber(value('quilos_malha')) || extracted.fabricKg;
const ribKg = normalizeNumber(value('quilos_ribana')) || extracted.ribKg;
const yieldPiecesPerKg = normalizeNumber(value('rendimento')) || extracted.yieldPiecesPerKg;
records.push({
tinyId,
number,
productSku,
productDescription,
quantity,
issueDate: normalizeDate(value('data_emissao')),
completedDate: normalizeDate(value('data_conclusao')),
orderReference: normalizeText(value('pedido_relacionado')),
notes,
familyKey: FAMILY_KEYS.has(familyKey) ? familyKey : null,
supplier,
lotCode,
rollQuantity,
fabricKg,
ribKg,
yieldPiecesPerKg
});
});
return { records, issues, totalRows: rows.length - 1 };
};
const refreshYieldRecommendations = async (client) => {
await client.query('DELETE FROM production_yield_recommendations;');
await client.query('DELETE FROM production_yield_supplier_recommendations;');
await client.query(`
INSERT INTO production_yield_recommendations (
family_key, sample_count, yield_sample_count, roll_sample_count,
avg_yield_pieces_per_kg, avg_kg_per_roll, recommended_units_per_roll,
last_completed_date, updated_at
)
SELECT
split_part(product_sku, '.', 1) AS family_key,
COUNT(*)::int,
COUNT(yield_pieces_per_kg)::int,
COUNT(*) FILTER (WHERE fabric_kg > 0 AND roll_quantity > 0)::int,
AVG(yield_pieces_per_kg),
AVG(fabric_kg / roll_quantity) FILTER (WHERE fabric_kg > 0 AND roll_quantity > 0),
AVG(yield_pieces_per_kg * (fabric_kg / roll_quantity)) FILTER (WHERE fabric_kg > 0 AND roll_quantity > 0 AND yield_pieces_per_kg > 0),
MAX(completed_date),
CURRENT_TIMESTAMP
FROM production_orders
WHERE status = 'finished'
AND split_part(product_sku, '.', 1) IN ('BLCS', 'BLOS', 'BLMC', 'BLPM', 'BLRM')
GROUP BY split_part(product_sku, '.', 1);
`);
await client.query(`
INSERT INTO production_yield_supplier_recommendations (
family_key, supplier, sample_count, yield_sample_count, roll_sample_count,
avg_yield_pieces_per_kg, avg_kg_per_roll, recommended_units_per_roll,
last_completed_date, updated_at
)
SELECT
split_part(product_sku, '.', 1) AS family_key,
supplier,
COUNT(*)::int,
COUNT(yield_pieces_per_kg)::int,
COUNT(*) FILTER (WHERE fabric_kg > 0 AND roll_quantity > 0)::int,
AVG(yield_pieces_per_kg),
AVG(fabric_kg / roll_quantity) FILTER (WHERE fabric_kg > 0 AND roll_quantity > 0),
AVG(yield_pieces_per_kg * (fabric_kg / roll_quantity)) FILTER (WHERE fabric_kg > 0 AND roll_quantity > 0 AND yield_pieces_per_kg > 0),
MAX(completed_date),
CURRENT_TIMESTAMP
FROM production_orders
WHERE status = 'finished'
AND supplier IS NOT NULL
AND split_part(product_sku, '.', 1) IN ('BLCS', 'BLOS', 'BLMC', 'BLPM', 'BLRM')
GROUP BY split_part(product_sku, '.', 1), supplier;
`);
};
const listProductionOrderImportData = async ({ runsPage = 1, runsPageSize = 5 } = {}) => {
const normalizedRunsPageSize = Math.max(1, Math.min(Number(runsPageSize) || 5, 20));
const normalizedRunsPage = Math.max(1, Number(runsPage) || 1);
const runsOffset = (normalizedRunsPage - 1) * normalizedRunsPageSize;
const [runs, runsCount, recommendations, supplierRecommendations] = await Promise.all([
pool.query(`SELECT id, filename, status, total_rows, imported_count, updated_count, skipped_count, failed_count, error_message, started_at, completed_at FROM production_order_import_runs ORDER BY started_at DESC LIMIT $1 OFFSET $2;`, [normalizedRunsPageSize, runsOffset]),
pool.query('SELECT COUNT(*)::int AS count FROM production_order_import_runs;'),
pool.query(`SELECT family_key, sample_count, yield_sample_count, roll_sample_count, avg_yield_pieces_per_kg, avg_kg_per_roll, recommended_units_per_roll, last_completed_date, updated_at FROM production_yield_recommendations ORDER BY family_key;`),
pool.query(`SELECT family_key, supplier, sample_count, yield_sample_count, roll_sample_count, avg_yield_pieces_per_kg, avg_kg_per_roll, recommended_units_per_roll, last_completed_date, updated_at FROM production_yield_supplier_recommendations ORDER BY family_key, supplier;`)
]);
return {
runs: runs.rows.map(row => ({
id: row.id, filename: row.filename, status: row.status, totalRows: row.total_rows,
imported: row.imported_count, updated: row.updated_count, skipped: row.skipped_count,
failed: row.failed_count, errorMessage: row.error_message || '', startedAt: row.started_at,
completedAt: row.completed_at
})),
runsTotal: Number(runsCount.rows[0]?.count || 0),
runsPage: normalizedRunsPage,
runsPageSize: normalizedRunsPageSize,
recommendations: recommendations.rows.map(row => ({
familyKey: row.family_key, sampleCount: Number(row.sample_count), yieldSampleCount: Number(row.yield_sample_count),
rollSampleCount: Number(row.roll_sample_count), avgYieldPiecesPerKg: row.avg_yield_pieces_per_kg === null ? null : Number(row.avg_yield_pieces_per_kg),
avgKgPerRoll: row.avg_kg_per_roll === null ? null : Number(row.avg_kg_per_roll),
recommendedUnitsPerRoll: row.recommended_units_per_roll === null ? null : Number(row.recommended_units_per_roll),
lastCompletedDate: row.last_completed_date || null, updatedAt: row.updated_at
})),
supplierRecommendations: supplierRecommendations.rows.map(row => ({
familyKey: row.family_key, supplier: row.supplier, sampleCount: Number(row.sample_count),
yieldSampleCount: Number(row.yield_sample_count), rollSampleCount: Number(row.roll_sample_count),
avgYieldPiecesPerKg: row.avg_yield_pieces_per_kg === null ? null : Number(row.avg_yield_pieces_per_kg),
avgKgPerRoll: row.avg_kg_per_roll === null ? null : Number(row.avg_kg_per_roll),
recommendedUnitsPerRoll: row.recommended_units_per_roll === null ? null : Number(row.recommended_units_per_roll),
lastCompletedDate: row.last_completed_date || null, updatedAt: row.updated_at
}))
};
};
const exportMissingProductionOrderDataCsv = async () => {
const result = await pool.query(`
SELECT tiny_id, number, product_sku, product_description, quantity, issue_date, completed_date, notes,
order_reference, supplier, lot_code, roll_quantity, fabric_kg, rib_kg, yield_pieces_per_kg
FROM production_orders
WHERE status = 'finished'
AND (
supplier IS NULL OR lot_code IS NULL OR roll_quantity IS NULL
OR fabric_kg IS NULL OR yield_pieces_per_kg IS NULL
)
ORDER BY completed_date DESC NULLS LAST, id DESC;
`);
const headers = [
'id_op', 'numero_op', 'sku_produto', 'descricao_produto', 'quantidade_produzida', 'data_emissao',
'data_conclusao', 'situacao', 'observacoes', 'pedido_relacionado', 'fornecedor', 'lote',
'quantidade_rolos', 'quilos_malha', 'quilos_ribana', 'rendimento', 'campos_pendentes'
];
const rows = result.rows.map((row) => {
const missing = [
!row.supplier && 'fornecedor',
!row.lot_code && 'lote',
!row.roll_quantity && 'quantidade_rolos',
!row.fabric_kg && 'quilos_malha',
!row.yield_pieces_per_kg && 'rendimento'
].filter(Boolean).join(', ');
return [
row.tiny_id, row.number, row.product_sku, row.product_description, row.quantity, row.issue_date,
row.completed_date, 'Finalizada', row.notes, row.order_reference, row.supplier, row.lot_code,
row.roll_quantity, row.fabric_kg, row.rib_kg, row.yield_pieces_per_kg, missing
];
});
return {
filename: `ops-finalizadas-pendentes-${new Date().toISOString().slice(0, 10)}.csv`,
totalRows: rows.length,
csv: [headers, ...rows].map(row => row.map(csvCell).join(',')).join('\r\n')
};
};
const importFinalizedProductionOrderCsv = async ({ filename, csv }) => {
const parsed = parseFinalizedProductionOrderCsv(csv);
const client = await pool.connect();
const safeFilename = normalizeText(filename) || 'ops-finalizadas.csv';
let runId;
let imported = 0;
let updated = 0;
let failed = 0;
try {
await client.query('BEGIN');
const run = await client.query(`
INSERT INTO production_order_import_runs (filename, status, total_rows, skipped_count)
VALUES ($1, 'running', $2, $3)
RETURNING id;
`, [safeFilename, parsed.totalRows, parsed.issues.length]);
runId = run.rows[0].id;
for (const record of parsed.records) {
try {
await client.query('SAVEPOINT production_order_import_record;');
const existing = await client.query('SELECT id FROM production_orders WHERE tiny_id = $1 OR number = $2 LIMIT 1;', [record.tinyId, record.number]);
const payload = JSON.stringify({ source: 'olist_finalized_op_csv', importedAt: new Date().toISOString(), record });
if (existing.rows[0]) {
await client.query(`
UPDATE production_orders SET tiny_id = $1, number = $2, status = 'finished', order_reference = NULLIF($3, ''), issue_date = $4,
completed_date = $5, product_sku = $6, product_description = $7, quantity = $8, unit = 'UN', integration_status = 'Olist CSV',
notes = NULLIF($9, ''), supplier = NULLIF($10, ''), lot_code = NULLIF($11, ''), roll_quantity = $12, fabric_kg = $13, rib_kg = $14,
yield_pieces_per_kg = $15, tiny_payload = $16::jsonb, updated_at = CURRENT_TIMESTAMP
WHERE id = $17;
`, [record.tinyId, record.number, record.orderReference, record.issueDate, record.completedDate, record.productSku, record.productDescription, record.quantity, record.notes, record.supplier, record.lotCode, record.rollQuantity, record.fabricKg, record.ribKg, record.yieldPiecesPerKg, payload, existing.rows[0].id]);
updated += 1;
} else {
await client.query(`
INSERT INTO production_orders (tiny_id, number, status, order_reference, issue_date, completed_date, product_sku, product_description, quantity, unit, integration_status, notes, supplier, lot_code, roll_quantity, fabric_kg, rib_kg, yield_pieces_per_kg, tiny_payload)
VALUES ($1, $2, 'finished', NULLIF($3, ''), $4, $5, $6, $7, $8, 'UN', 'Olist CSV', NULLIF($9, ''), NULLIF($10, ''), NULLIF($11, ''), $12, $13, $14, $15, $16::jsonb);
`, [record.tinyId, record.number, record.orderReference, record.issueDate, record.completedDate, record.productSku, record.productDescription, record.quantity, record.notes, record.supplier, record.lotCode, record.rollQuantity, record.fabricKg, record.ribKg, record.yieldPiecesPerKg, payload]);
imported += 1;
}
await client.query('RELEASE SAVEPOINT production_order_import_record;');
} catch (error) {
await client.query('ROLLBACK TO SAVEPOINT production_order_import_record;');
failed += 1;
parsed.issues.push({ number: record.number, message: error.message });
}
}
await refreshYieldRecommendations(client);
await client.query(`
UPDATE production_order_import_runs
SET status = $1, imported_count = $2, updated_count = $3, skipped_count = $4, failed_count = $5, error_message = $6, completed_at = CURRENT_TIMESTAMP
WHERE id = $7;
`, [failed ? 'completed_with_errors' : 'completed', imported, updated, parsed.issues.length, failed, parsed.issues.slice(0, 20).map(issue => `OP ${issue.number || `linha ${issue.line}`}: ${issue.message}`).join('\n') || null, runId]);
await client.query('COMMIT');
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
return { runId, totalRows: parsed.totalRows, imported, updated, skipped: parsed.issues.length, failed, issues: parsed.issues.slice(0, 20), ...(await listProductionOrderImportData()) };
};
module.exports = {
exportMissingProductionOrderDataCsv,
extractObservationFields,
importFinalizedProductionOrderCsv,
listProductionOrderImportData,
parseCsv,
parseFinalizedProductionOrderCsv
};

File diff suppressed because it is too large Load Diff

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -1,245 +0,0 @@
const crypto = require('crypto');
const { pool } = require('../db');
const SYSTEM_OPERATIONAL_USER_NAME = 'Super Admin';
const HASH_ALGORITHM = 'scrypt';
const KEY_LENGTH = 64;
const normalizeEmail = (email) => String(email || '').trim().toLowerCase();
const generatePassword = () => {
return crypto.randomBytes(9).toString('base64url');
};
const hashPassword = (password) => {
const salt = crypto.randomBytes(16).toString('hex');
const hash = crypto.scryptSync(password, salt, KEY_LENGTH).toString('hex');
return `${HASH_ALGORITHM}:${salt}:${hash}`;
};
const verifyPassword = (password, passwordHash) => {
const [algorithm, salt, storedHash] = String(passwordHash || '').split(':');
if (algorithm !== HASH_ALGORITHM || !salt || !storedHash) return false;
const hash = crypto.scryptSync(password, salt, KEY_LENGTH);
const storedBuffer = Buffer.from(storedHash, 'hex');
if (storedBuffer.length !== hash.length) return false;
return crypto.timingSafeEqual(hash, storedBuffer);
};
const publicUserFields = (row) => ({
id: row.id,
name: row.name,
email: row.email,
isActive: row.is_active,
createdAt: row.created_at,
updatedAt: row.updated_at
});
const findUserByEmail = async (email) => {
const normalizedEmail = normalizeEmail(email);
const result = await pool.query(
`SELECT id, name, email, password_hash, is_active, created_at, updated_at
FROM app_users
WHERE LOWER(email) = $1
LIMIT 1`,
[normalizedEmail]
);
return result.rows[0] || null;
};
const listUsers = async () => {
const result = await pool.query(
`SELECT id, name, email, is_active, created_at, updated_at
FROM app_users
ORDER BY created_at DESC, id DESC`
);
return result.rows.map(publicUserFields);
};
// This deliberately exposes only the identity needed to assign operational
// responsibility. It is available to every signed-in Graphs user.
const listActiveOperationalUsers = async () => {
const result = await pool.query(
`SELECT id, name
FROM app_users
WHERE is_active = TRUE
ORDER BY LOWER(name), id`
);
return [
{ id: null, name: SYSTEM_OPERATIONAL_USER_NAME, isSystem: true },
...result.rows.map((row) => ({
id: Number(row.id),
name: row.name,
isSystem: false
}))
];
};
const getActiveOperationalUserById = async (id, client = pool) => {
const result = await client.query(
`SELECT id, name
FROM app_users
WHERE id = $1 AND is_active = TRUE
LIMIT 1`,
[id]
);
return result.rows[0] || null;
};
const createUser = async ({ name, email, password }) => {
const normalizedName = String(name || '').trim();
const normalizedEmail = normalizeEmail(email);
const generatedPassword = !password;
const plainPassword = String(password || generatePassword()).trim();
if (!normalizedName) {
const error = new Error('Name is required');
error.statusCode = 400;
throw error;
}
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(normalizedEmail)) {
const error = new Error('Valid email is required');
error.statusCode = 400;
throw error;
}
if (plainPassword.length < 6) {
const error = new Error('Password must have at least 6 characters');
error.statusCode = 400;
throw error;
}
const passwordHash = hashPassword(plainPassword);
try {
const result = await pool.query(
`INSERT INTO app_users (name, email, password_hash)
VALUES ($1, $2, $3)
RETURNING id, name, email, is_active, created_at, updated_at`,
[normalizedName, normalizedEmail, passwordHash]
);
return {
user: publicUserFields(result.rows[0]),
password: plainPassword,
generatedPassword
};
} catch (error) {
if (error.code === '23505') {
const duplicateError = new Error('A user with this email already exists');
duplicateError.statusCode = 409;
throw duplicateError;
}
throw error;
}
};
const findUserById = async (id) => {
const result = await pool.query(
`SELECT id, name, email, password_hash, is_active, created_at, updated_at
FROM app_users
WHERE id = $1
LIMIT 1`,
[id]
);
return result.rows[0] || null;
};
const updateUser = async (id, { name, email, password, isActive }) => {
const existingUser = await findUserById(id);
if (!existingUser) {
const error = new Error('User not found');
error.statusCode = 404;
throw error;
}
const normalizedName = String(name ?? existingUser.name).trim();
const normalizedEmail = normalizeEmail(email ?? existingUser.email);
const normalizedIsActive = typeof isActive === 'boolean' ? isActive : existingUser.is_active;
const normalizedPassword = typeof password === 'string' ? password.trim() : '';
if (!normalizedName) {
const error = new Error('Name is required');
error.statusCode = 400;
throw error;
}
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(normalizedEmail)) {
const error = new Error('Valid email is required');
error.statusCode = 400;
throw error;
}
if (normalizedPassword && normalizedPassword.length < 6) {
const error = new Error('Password must have at least 6 characters');
error.statusCode = 400;
throw error;
}
const passwordHash = normalizedPassword ? hashPassword(normalizedPassword) : existingUser.password_hash;
try {
const result = await pool.query(
`UPDATE app_users
SET name = $1,
email = $2,
password_hash = $3,
is_active = $4,
updated_at = CURRENT_TIMESTAMP
WHERE id = $5
RETURNING id, name, email, is_active, created_at, updated_at`,
[normalizedName, normalizedEmail, passwordHash, normalizedIsActive, id]
);
return publicUserFields(result.rows[0]);
} catch (error) {
if (error.code === '23505') {
const duplicateError = new Error('A user with this email already exists');
duplicateError.statusCode = 409;
throw duplicateError;
}
throw error;
}
};
const deleteUser = async (id) => {
const result = await pool.query(
`DELETE FROM app_users
WHERE id = $1
RETURNING id`,
[id]
);
if (!result.rowCount) {
const error = new Error('User not found');
error.statusCode = 404;
throw error;
}
};
module.exports = {
createUser,
deleteUser,
findUserById,
findUserByEmail,
generatePassword,
getActiveOperationalUserById,
listActiveOperationalUsers,
listUsers,
normalizeEmail,
publicUserFields,
SYSTEM_OPERATIONAL_USER_NAME,
updateUser,
verifyPassword
};

File diff suppressed because it is too large Load Diff

View File

@@ -1,161 +0,0 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const {
buildWhatsappCampaignPayload,
formatProductList,
formatProductNameForDisplay,
groupCampaignRows,
groupCampaignRowsByBaseProduct,
isCampaignEligibleProductName,
mapCampaignProducts
} = require('../services/campaignFormatter');
const row = (overrides) => ({
id: 1,
base_product_name: 'BASE LISA CAMISETA COR BRANCO',
produto_id: 'SKU-1',
nome: 'BASE LISA CAMISETA COR BRANCO TAMANHO - P',
saldo: 10,
delta_estoque: 10,
status: 'pending',
attempts: 0,
last_error: null,
created_at: '2026-05-28T10:00:00.000Z',
updated_at: '2026-05-28T10:00:00.000Z',
sent_at: null,
...overrides
});
test('formatProductList uses inline bullet separators', () => {
assert.equal(formatProductList([]), '');
assert.equal(formatProductList(['BONÉ - PRETO']), 'Boné - Preto');
assert.equal(formatProductList(['BONÉ - PRETO', 'BASE BRANCA']), 'Boné - Preto • Base Branca');
assert.equal(
formatProductList(['BONÉ - PRETO', 'BASE BRANCA', 'BASE PRETA']),
'Boné - Preto • Base Branca • Base Preta'
);
});
test('formatProductNameForDisplay converts campaign product names to title case', () => {
assert.equal(
formatProductNameForDisplay('BASE LISA MOLETOM CANGURU COR PRETO'),
'Moletom Canguru Premium Cor Preto'
);
assert.equal(formatProductNameForDisplay('BONÉ - BRANCO'), 'Boné - Branco');
});
test('formatProductNameForDisplay applies customer-facing campaign aliases', () => {
assert.equal(
formatProductNameForDisplay('BASE LISA CAMISETA COR AZUL'),
'Camiseta Premium Cor Azul'
);
assert.equal(
formatProductNameForDisplay('BASE LISA OVER SIZE COR PRETO'),
'Camiseta Premium Over Size Cor Preto'
);
assert.equal(
formatProductNameForDisplay('BASE LISA MOLETOM CANGURU COR PRETO'),
'Moletom Canguru Premium Cor Preto'
);
});
test('isCampaignEligibleProductName allows only customer-facing apparel campaigns', () => {
[
'Camiseta Premium Cor Bordo',
'Camiseta Premium Cor Preto',
'Moletom Canguru Premium Cor Preto',
'IMPRESSÃO DTF PERSONALIZADO 57X100 (1 METRO)',
'BASE LISA CAMISETA COR PRETO TAMANHO - G',
'BASE LISA MOLETOM CANGURU COR PRETO'
].forEach(name => {
assert.equal(isCampaignEligibleProductName(name), true, name);
});
[
'Ilhos Com Arruela',
'Atacador 001 Chato Preto 1,20 Mt',
'2099 Ribana 2x1 Cor Bordo',
'2001.09 Ribana 2x1 Cor Cinza',
'2001.09 Malha Camiseta 30oe Cor Cinza',
'201 Malha Camiseta 30oe Cor Preto',
'4006 Malha Camiseta 30oe Cor Marinho',
'TINTA DTF 1 LITRO - BRANCO',
'FILME DTF ROLO 60CM',
'POLIAMIDA EM PÓ PARA DTF - 1 KG',
'SALDO ESTOQUE CAMISETA'
].forEach(name => {
assert.equal(isCampaignEligibleProductName(name), false, name);
});
});
test('isCampaignEligibleProductName keeps accessories out of WhatsApp apparel campaigns', () => {
assert.equal(isCampaignEligibleProductName('BONÉ - PRETO'), false);
});
test('mapCampaignProducts accumulates split deltas by base product', () => {
const groups = groupCampaignRowsByBaseProduct([
row({ id: 1, delta_estoque: 10, produto_id: 'SKU-P', nome: 'Produto Split TAMANHO - P' }),
row({ id: 2, delta_estoque: 50, produto_id: 'SKU-M', nome: 'Produto Split TAMANHO - M' }),
row({ id: 3, delta_estoque: 40, produto_id: 'SKU-G', nome: 'Produto Split TAMANHO - G' })
]);
const products = mapCampaignProducts(groups);
assert.equal(products.length, 1);
assert.equal(products[0].total_delta, 100);
assert.deepEqual(products[0].itemIds, [3, 2, 1]);
assert.deepEqual(products[0].sizes.map(size => size.id), ['SKU-G', 'SKU-M', 'SKU-P']);
});
test('buildWhatsappCampaignPayload combines multiple ready products into one message payload', () => {
const products = mapCampaignProducts(groupCampaignRowsByBaseProduct([
row({
id: 1,
base_product_name: 'BONÉ - PRETO',
produto_id: 'BONE-P',
nome: 'BONÉ - PRETO TAMANHO - P',
delta_estoque: 100,
created_at: '2026-05-28T10:00:00.000Z'
}),
row({
id: 2,
base_product_name: 'BASE LISA CAMISETA COR BRANCO',
produto_id: 'BASE-P',
nome: 'BASE LISA CAMISETA COR BRANCO TAMANHO - P',
delta_estoque: 100,
created_at: '2026-05-28T11:00:00.000Z'
})
]));
const payload = buildWhatsappCampaignPayload(products, [{ nome: 'Cliente', fone: '5511999999999' }]);
assert.equal(payload.productsText, 'Boné - Preto • Camiseta Premium Cor Branco');
assert.equal(payload.baseProduct, payload.productsText);
assert.equal(payload.total_delta, 200);
assert.equal(payload.products.length, 2);
assert.equal(payload.sizes.length, 2);
assert.deepEqual(Object.keys(payload.products[0]).includes('itemIds'), false);
});
test('groupCampaignRows summarizes rows by base product and status', () => {
const groups = groupCampaignRows([
row({ id: 1, delta_estoque: 60, attempts: 1, updated_at: '2026-05-28T10:00:00.000Z' }),
row({ id: 2, delta_estoque: 40, attempts: 2, updated_at: '2026-05-28T10:05:00.000Z' }),
row({
id: 3,
base_product_name: 'BONÉ - PRETO',
status: 'failed',
delta_estoque: 100,
attempts: 3,
last_error: 'Webhook failed',
updated_at: '2026-05-28T11:00:00.000Z'
})
]);
assert.equal(groups.length, 2);
assert.equal(groups[0].baseProductName, 'BONÉ - PRETO');
assert.equal(groups[0].lastError, 'Webhook failed');
assert.equal(groups[1].baseProductName, 'BASE LISA CAMISETA COR BRANCO');
assert.equal(groups[1].totalDelta, 100);
assert.equal(groups[1].rowCount, 2);
assert.equal(groups[1].attempts, 2);
});

View File

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

View File

@@ -1,53 +0,0 @@
const assert = require('node:assert/strict');
const test = require('node:test');
const {
normalizeFamilyKey,
normalizeFamilyYields,
normalizeProductType,
normalizeProductOverrides
} = require('../services/cuttingSettingsService');
test('normalizeFamilyKey accepts only known cut families', () => {
assert.equal(normalizeFamilyKey('blcs'), 'BLCS');
assert.equal(normalizeFamilyKey(' BLOS '), 'BLOS');
assert.equal(normalizeFamilyKey('blrm'), 'BLRM');
assert.equal(normalizeFamilyKey('OUTROS'), '');
assert.equal(normalizeFamilyKey('unknown'), '');
});
test('normalizeFamilyYields keeps positive numeric yield rules', () => {
assert.deepEqual(normalizeFamilyYields({
BLCS: '50',
BLOS: 0,
BLMC: -1,
BLPM: '12.5',
BLRM: '17',
OUTROS: 99
}), {
BLCS: 50,
BLPM: 12.5,
BLRM: 17
});
});
test('normalizeProductType accepts only known planning product types', () => {
assert.equal(normalizeProductType('finished_apparel'), 'finished_apparel');
assert.equal(normalizeProductType('raw_material'), 'raw_material');
assert.equal(normalizeProductType('finished_product'), '');
assert.equal(normalizeProductType('unknown type'), '');
});
test('normalizeProductOverrides trims and removes empty overrides', () => {
assert.deepEqual(normalizeProductOverrides({
' SKU-1 ': { familyKey: 'blcs', color: ' Preto ', size: ' m ', productType: 'finished_apparel', planningNotes: ' revisar corte ' },
'SKU-2': { familyKey: 'OUTROS', color: '', size: '' },
'SKU-3': { familyKey: '', color: ' Branco ', size: '' },
'SKU-4': { productType: 'raw_material' },
'SKU-5': null
}), {
'SKU-1': { familyKey: 'BLCS', color: 'Preto', size: 'M', productType: 'finished_apparel', planningNotes: 'revisar corte' },
'SKU-3': { familyKey: '', color: 'Branco', size: '', productType: '', planningNotes: '' },
'SKU-4': { familyKey: '', color: '', size: '', productType: 'raw_material', planningNotes: '' }
});
});

View File

@@ -1,117 +0,0 @@
const assert = require('node:assert/strict');
const test = require('node:test');
const { formatOrderRow, normalizeOrderDate, normalizeOrderPayload } = require('../mappers/orderMapper');
test('normalizeOrderDate accepts Brazilian display dates', () => {
assert.equal(normalizeOrderDate('28/05/2026'), '2026-05-28');
assert.equal(normalizeOrderDate('28-05-2026'), '2026-05-28');
});
test('normalizeOrderDate accepts ISO-like dates', () => {
assert.equal(normalizeOrderDate('2026-05-28'), '2026-05-28');
assert.equal(normalizeOrderDate('2026/05/28 10:30:00'), '2026-05-28');
});
test('normalizeOrderDate rejects invalid dates', () => {
assert.equal(normalizeOrderDate(''), null);
assert.equal(normalizeOrderDate('not a date'), null);
assert.equal(normalizeOrderDate('31/02/2026'), null);
});
test('normalizeOrderPayload includes normalized date without changing display date', () => {
const payload = normalizeOrderPayload({
Nome_Cliente: 'Cliente Teste',
Data_Pedido: '28/05/2026',
Valor_Pedido: '120.50',
ID_Produto: 'SKU-1',
Descricao_Produto: 'Produto',
Quantidade: '2',
Valor_Unitario: '60.25',
ID_Pedido: 'ORDER-1',
Fone_Cliente: '(16) 99999-9999'
});
assert.equal(payload[1], '28/05/2026');
assert.equal(payload[2], '2026-05-28');
});
test('normalizeOrderPayload maps Tiny ERP metadata aliases', () => {
const payload = normalizeOrderPayload({
Nome_Cliente: 'Cliente Teste',
Data_Pedido: '28/05/2026',
Valor_Pedido: '120.50',
ID_Produto: 'SKU-1',
Descricao_Produto: 'Produto',
Quantidade: '2',
Valor_Unitario: '60.25',
ID_Pedido: 'ORDER-1',
Nome_Fantasia: 'Cliente Fantasia',
ID_Vendedor: 123,
Nome_Vendedor: 'Maria',
Nome_Ecommerce: 'Mercado Livre',
Canal_Venda: 'Online',
Numero_Ecommerce: 'EC-987'
});
assert.equal(payload[10], 'Cliente Fantasia');
assert.equal(payload[11], '123');
assert.equal(payload[12], 'Maria');
assert.equal(payload[13], 'Mercado Livre');
assert.equal(payload[14], 'Online');
assert.equal(payload[15], 'EC-987');
});
test('normalizeOrderPayload maps lower-case metadata names', () => {
const payload = normalizeOrderPayload({
Nome_Cliente: 'Cliente Teste',
Data_Pedido: '28/05/2026',
Valor_Pedido: '120.50',
ID_Produto: 'SKU-1',
Descricao_Produto: 'Produto',
Quantidade: '2',
Valor_Unitario: '60.25',
ID_Pedido: 'ORDER-1',
cliente_nome_fantasia: 'Fantasia Lower',
id_vendedor: 'VEN-7',
nome_vendedor: 'Joao',
marketplace: 'Shopee',
canal_venda: 'Marketplace',
numero_ecommerce: '100200'
});
assert.equal(payload[10], 'Fantasia Lower');
assert.equal(payload[11], 'VEN-7');
assert.equal(payload[12], 'Joao');
assert.equal(payload[13], 'Shopee');
assert.equal(payload[14], 'Marketplace');
assert.equal(payload[15], '100200');
});
test('formatOrderRow returns Tiny ERP metadata fields', () => {
const row = formatOrderRow({
cliente_nome: 'Cliente Teste',
data_pedido: '28/05/2026',
valor_pedido: '120.50',
produto_id: 'SKU-1',
produto_descricao: 'Produto',
quantidade: 2,
valor_unitario: '60.25',
created_at: '2026-05-28T12:00:00.000Z',
pedido_id: 'ORDER-1',
cliente_fone: '(16) 99999-9999',
cliente_nome_fantasia: 'Cliente Fantasia',
id_vendedor: '123',
nome_vendedor: 'Maria',
marketplace: 'Mercado Livre',
canal_venda: 'Online',
numero_ecommerce: 'EC-987'
});
assert.equal(row.cliente_nome_fantasia, 'Cliente Fantasia');
assert.equal(row.id_vendedor, '123');
assert.equal(row.nome_vendedor, 'Maria');
assert.equal(row.marketplace, 'Mercado Livre');
assert.equal(row.canal_venda, 'Online');
assert.equal(row.numero_ecommerce, 'EC-987');
});

View File

@@ -1,140 +0,0 @@
const assert = require('node:assert/strict');
const test = require('node:test');
const runUpsertWithPayload = async (payload) => {
const queries = [];
const client = {
query: async (sql, params) => {
queries.push({ sql, params });
return { rows: [] };
},
release: () => {
queries.push({ sql: 'RELEASE' });
}
};
const dbPath = require.resolve('../db');
const servicePath = require.resolve('../services/ordersService');
const originalDbCache = require.cache[dbPath];
const originalServiceCache = require.cache[servicePath];
delete require.cache[servicePath];
require.cache[dbPath] = {
id: dbPath,
filename: dbPath,
loaded: true,
exports: {
pool: {
connect: async () => client
}
}
};
try {
const { upsertOrders } = require('../services/ordersService');
await upsertOrders(payload);
} finally {
delete require.cache[servicePath];
if (originalServiceCache) {
require.cache[servicePath] = originalServiceCache;
}
if (originalDbCache) {
require.cache[dbPath] = originalDbCache;
} else {
delete require.cache[dbPath];
}
}
return queries.find(query => query.sql.includes('INSERT INTO orders'));
};
const assertPreservesEmptyConflictValue = (sql, fieldName) => {
assert.match(
sql,
new RegExp(`${fieldName} = COALESCE\\(NULLIF\\(EXCLUDED\\.${fieldName}, ''\\), orders\\.${fieldName}\\)`)
);
};
test('upsertOrders persists non-empty Tiny ERP metadata columns', async () => {
const insert = await runUpsertWithPayload([{
Nome_Cliente: 'Cliente Teste',
Data_Pedido: '28/05/2026',
Valor_Pedido: '120.50',
ID_Produto: 'SKU-1',
Descricao_Produto: 'Produto',
Quantidade: '2',
Valor_Unitario: '60.25',
ID_Pedido: 'ORDER-1',
Fone_Cliente: '(16) 99999-9999',
cliente_nome_fantasia: 'Cliente Fantasia',
id_vendedor: 'VEN-1',
nome_vendedor: 'Maria',
marketplace: 'Mercado Livre',
canal_venda: 'Online',
numero_ecommerce: 'EC-987'
}]);
assert.ok(insert);
assert.equal(insert.params.length, 16);
assert.deepEqual(insert.params.slice(9), [
'(16) 99999-9999',
'Cliente Fantasia',
'VEN-1',
'Maria',
'Mercado Livre',
'Online',
'EC-987'
]);
});
test('upsertOrders preserves existing phone and metadata when incoming values are empty', async () => {
const insert = await runUpsertWithPayload([{
Nome_Cliente: 'Cliente Teste',
Data_Pedido: '28/05/2026',
Valor_Pedido: '120.50',
ID_Produto: 'SKU-1',
Descricao_Produto: 'Produto',
Quantidade: '2',
Valor_Unitario: '60.25',
ID_Pedido: 'ORDER-1',
Fone_Cliente: '',
cliente_nome_fantasia: '',
id_vendedor: '',
nome_vendedor: '',
marketplace: '',
canal_venda: '',
numero_ecommerce: ''
}]);
assert.ok(insert);
[
'cliente_fone',
'cliente_nome_fantasia',
'id_vendedor',
'nome_vendedor',
'marketplace',
'canal_venda',
'numero_ecommerce'
].forEach(fieldName => assertPreservesEmptyConflictValue(insert.sql, fieldName));
assert.deepEqual(insert.params.slice(9), ['', '', '', '', '', '', '']);
});
test('upsertOrders keeps existing payloads without Tiny ERP metadata working', async () => {
const insert = await runUpsertWithPayload([{
Nome_Cliente: 'Cliente Teste',
Data_Pedido: '28/05/2026',
Valor_Pedido: '120.50',
ID_Produto: 'SKU-1',
Descricao_Produto: 'Produto',
Quantidade: '2',
Valor_Unitario: '60.25',
ID_Pedido: 'ORDER-1'
}]);
assert.ok(insert);
assert.equal(insert.params.length, 16);
assert.deepEqual(insert.params.slice(9), ['', '', '', '', '', '', '']);
assert.match(insert.sql, /quantidade = EXCLUDED\.quantidade/);
assert.match(insert.sql, /valor_unitario = EXCLUDED\.valor_unitario/);
assertPreservesEmptyConflictValue(insert.sql, 'cliente_fone');
assertPreservesEmptyConflictValue(insert.sql, 'cliente_nome_fantasia');
});

View File

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

View File

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

View File

@@ -1,29 +0,0 @@
const assert = require('node:assert/strict');
const test = require('node:test');
const { getBaseProductName } = require('../mappers/stockMapper');
test('getBaseProductName strips TAMANHO suffixes', () => {
assert.equal(
getBaseProductName('BASE LISA CAMISETA COR BRANCO TAMANHO - P'),
'BASE LISA CAMISETA COR BRANCO'
);
});
test('getBaseProductName strips trailing size suffixes without removing colors', () => {
assert.equal(
getBaseProductName('BASE LISA MOLETOM CANGURU COR PRETO - M'),
'BASE LISA MOLETOM CANGURU COR PRETO'
);
assert.equal(
getBaseProductName('BASE LISA MOLETOM CANGURU COR PRETO - M/G/GG'),
'BASE LISA MOLETOM CANGURU COR PRETO'
);
assert.equal(getBaseProductName('BONÉ - BRANCO'), 'BONÉ - BRANCO');
});
test('getBaseProductName preserves etiqueta product variants', () => {
assert.equal(getBaseProductName('ETIQUETA 10X5 851UN'), 'ETIQUETA 10X5 851UN');
assert.equal(getBaseProductName('ETIQUETA BRANCA TAMANHO 08'), 'ETIQUETA BRANCA TAMANHO 08');
assert.equal(getBaseProductName('ETIQUETA BRANCA TAMANHO GG'), 'ETIQUETA BRANCA TAMANHO GG');
});

View File

@@ -1,3 +1,5 @@
version: '3.8'
services:
db:
image: postgres:15-alpine
@@ -26,20 +28,6 @@ services:
- ADMIN_EMAIL=${ADMIN_EMAIL:-admin@admin.com}
- ADMIN_PASSWORD=${ADMIN_PASSWORD:-admin123}
- JWT_SECRET=${JWT_SECRET:-super_secret_jwt_key_123}
- N8N_WHATSAPP_TRIGGER_URL=${N8N_WHATSAPP_TRIGGER_URL:-http://localhost:5678/webhook/whatsapp}
- OLIST_CLIENT_ID=${OLIST_CLIENT_ID:-}
- OLIST_CLIENT_SECRET=${OLIST_CLIENT_SECRET:-}
- OLIST_REDIRECT_URI=${OLIST_REDIRECT_URI:-}
- OLIST_FRONTEND_URL=${OLIST_FRONTEND_URL:-}
- OLIST_TOKEN_ENCRYPTION_KEY=${OLIST_TOKEN_ENCRYPTION_KEY:-}
- OLIST_SYNC_ENABLED=${OLIST_SYNC_ENABLED:-false}
- OLIST_SYNC_INTERVAL_MS=${OLIST_SYNC_INTERVAL_MS:-86400000}
- OLIST_SYNC_REQUEST_DELAY_MS=${OLIST_SYNC_REQUEST_DELAY_MS:-2500}
- TURNSTILE_SITE_KEY=${TURNSTILE_SITE_KEY:-}
- TURNSTILE_SITEKEY=${TURNSTILE_SITEKEY:-}
- VITE_TURNSTILE_SITE_KEY=${VITE_TURNSTILE_SITE_KEY:-}
- TURNSTILE_SECRET=${TURNSTILE_SECRET:-}
- TURNSTILE_SECRET_KEY=${TURNSTILE_SECRET_KEY:-}
depends_on:
- db
restart: unless-stopped
@@ -50,7 +38,7 @@ services:
image: gitea.blyzer.com.br/blyzer/graphs-frontend:latest
container_name: graph_frontend
ports:
- "3002:80"
- "3005:80"
depends_on:
- backend
restart: unless-stopped

33
fake-data.cjs Normal file
View File

@@ -0,0 +1,33 @@
const products = [];
// Group 1: High Quantity, Low Price (Top 10 in Bar Chart, won't show in Pie Chart)
for (let i = 0; i < 10; i++) {
products.push({
Nome_Cliente: "Fake Client A" + i,
Data_Pedido: "06-05-2026",
Valor_Pedido: 100,
ID_Produto: "QA" + i,
Descricao_Produto: "Produto Muito Vendido " + i,
Quantidade: 1000 + i, // High sales
Valor_Unitario: 0.10 // Low revenue
});
}
// Group 2: Low Quantity, High Price (Top 10 in Pie Chart, won't show in Bar Chart)
for (let i = 0; i < 10; i++) {
products.push({
Nome_Cliente: "Fake Client B" + i,
Data_Pedido: "06-05-2026",
Valor_Pedido: 10000,
ID_Produto: "QB" + i,
Descricao_Produto: "Produto Muito Caro " + i,
Quantidade: 1, // Low sales
Valor_Unitario: 10000 + i // High revenue
});
}
fetch('http://localhost:3004/api/data', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'x-api-key': 'nexstar_secret_key_123' },
body: JSON.stringify(products)
}).then(res => console.log("Status:", res.status)).catch(console.error);

42
fake-data.js Normal file
View File

@@ -0,0 +1,42 @@
// Clear previous data for a clean slate
const { Pool } = require('pg');
const pool = new Pool({ connectionString: 'postgres://graphuser:graphpassword@localhost:5432/graphdb' });
async function run() {
await pool.query('TRUNCATE TABLE orders RESTART IDENTITY;');
// Group 1: High Quantity, Low Price (Top 10 in Bar Chart, won't show in Pie Chart)
const group1 = Array.from({length: 10}, (_, i) => ({
Nome_Cliente: "Fake Client A" + i,
Data_Pedido: "06-05-2026",
Valor_Pedido: 100,
ID_Produto: "QA" + i,
Descricao_Produto: "Produto Muito Vendido " + i,
Quantidade: 1000 + i, // High sales
Valor_Unitario: 0.10 // Low revenue
}));
// Group 2: Low Quantity, High Price (Top 10 in Pie Chart, won't show in Bar Chart)
const group2 = Array.from({length: 10}, (_, i) => ({
Nome_Cliente: "Fake Client B" + i,
Data_Pedido: "06-05-2026",
Valor_Pedido: 10000,
ID_Produto: "QB" + i,
Descricao_Produto: "Produto Muito Caro " + i,
Quantidade: 1, // Low sales
Valor_Unitario: 10000 + i // High revenue
}));
const products = [...group1, ...group2];
const res = await fetch('http://localhost:3004/api/data', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'x-api-key': 'nexstar_secret_key_123' },
body: JSON.stringify(products)
});
console.log(res.status);
process.exit(0);
}
run();

View File

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

View File

@@ -1,5 +1,5 @@
<!DOCTYPE html>
<html lang="en" class="dark" data-theme="dark">
<html lang="en" class="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />

184
src/App.css Normal file
View File

@@ -0,0 +1,184 @@
.counter {
font-size: 16px;
padding: 5px 10px;
border-radius: 5px;
color: var(--accent);
background: var(--accent-bg);
border: 2px solid transparent;
transition: border-color 0.3s;
margin-bottom: 24px;
&:hover {
border-color: var(--accent-border);
}
&:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
}
.hero {
position: relative;
.base,
.framework,
.vite {
inset-inline: 0;
margin: 0 auto;
}
.base {
width: 170px;
position: relative;
z-index: 0;
}
.framework,
.vite {
position: absolute;
}
.framework {
z-index: 1;
top: 34px;
height: 28px;
transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
scale(1.4);
}
.vite {
z-index: 0;
top: 107px;
height: 26px;
width: auto;
transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
scale(0.8);
}
}
#center {
display: flex;
flex-direction: column;
gap: 25px;
place-content: center;
place-items: center;
flex-grow: 1;
@media (max-width: 1024px) {
padding: 32px 20px 24px;
gap: 18px;
}
}
#next-steps {
display: flex;
border-top: 1px solid var(--border);
text-align: left;
& > div {
flex: 1 1 0;
padding: 32px;
@media (max-width: 1024px) {
padding: 24px 20px;
}
}
.icon {
margin-bottom: 16px;
width: 22px;
height: 22px;
}
@media (max-width: 1024px) {
flex-direction: column;
text-align: center;
}
}
#docs {
border-right: 1px solid var(--border);
@media (max-width: 1024px) {
border-right: none;
border-bottom: 1px solid var(--border);
}
}
#next-steps ul {
list-style: none;
padding: 0;
display: flex;
gap: 8px;
margin: 32px 0 0;
.logo {
height: 18px;
}
a {
color: var(--text-h);
font-size: 16px;
border-radius: 6px;
background: var(--social-bg);
display: flex;
padding: 6px 12px;
align-items: center;
gap: 8px;
text-decoration: none;
transition: box-shadow 0.3s;
&:hover {
box-shadow: var(--shadow);
}
.button-icon {
height: 18px;
width: 18px;
}
}
@media (max-width: 1024px) {
margin-top: 20px;
flex-wrap: wrap;
justify-content: center;
li {
flex: 1 1 calc(50% - 8px);
}
a {
width: 100%;
justify-content: center;
box-sizing: border-box;
}
}
}
#spacer {
height: 88px;
border-top: 1px solid var(--border);
@media (max-width: 1024px) {
height: 48px;
}
}
.ticks {
position: relative;
width: 100%;
&::before,
&::after {
content: '';
position: absolute;
top: -4.5px;
border: 5px solid transparent;
}
&::before {
left: 0;
border-left-color: var(--border);
}
&::after {
right: 0;
border-right-color: var(--border);
}
}

View File

@@ -1,31 +1,14 @@
import React, { Suspense } from 'react';
import React from 'react';
import { Routes, Route, Navigate, useLocation } from 'react-router-dom';
import { Loader2 } from 'lucide-react';
import Layout from './components/Layout';
import Dashboard from './pages/Dashboard';
import Products from './pages/Products';
import ProductDetails from './pages/ProductDetails';
import Clients from './pages/Clients';
import ClientDetails from './pages/ClientDetails';
import Login from './pages/Login';
import { isAuthenticated } from './dataService';
const Dashboard = React.lazy(() => import('./pages/Dashboard'));
const Products = React.lazy(() => import('./pages/Products'));
const ProductDetails = React.lazy(() => import('./pages/ProductDetails'));
const ProductGroupDetails = React.lazy(() => import('./pages/ProductGroupDetails'));
const Replenishment = React.lazy(() => import('./pages/Replenishment'));
const Cutting = React.lazy(() => import('./pages/Cutting'));
const PlanningIssues = React.lazy(() => import('./pages/PlanningIssues'));
const ProductionOrders = React.lazy(() => import('./pages/ProductionOrders'));
const ProductionPlans = React.lazy(() => import('./pages/ProductionPlans'));
const ProductionQuality = React.lazy(() => import('./pages/ProductionQuality'));
const Supplies = React.lazy(() => import('./pages/Supplies'));
const Clients = React.lazy(() => import('./pages/Clients'));
const ClientDetails = React.lazy(() => import('./pages/ClientDetails'));
const Sellers = React.lazy(() => import('./pages/Sellers'));
const SellerDetails = React.lazy(() => import('./pages/SellerDetails'));
const Campaigns = React.lazy(() => import('./pages/Campaigns'));
const Rfm = React.lazy(() => import('./pages/Rfm'));
const Registrations = React.lazy(() => import('./pages/Registrations'));
const Login = React.lazy(() => import('./pages/Login'));
const AdminUsers = React.lazy(() => import('./pages/AdminUsers'));
const OlistSync = React.lazy(() => import('./pages/OlistSync'));
function PrivateRoute({ children }: { children: React.ReactNode }) {
const location = useLocation();
if (!isAuthenticated()) {
@@ -34,45 +17,19 @@ function PrivateRoute({ children }: { children: React.ReactNode }) {
return children;
}
const RouteFallback = () => (
<div className="flex min-h-screen items-center justify-center bg-dark-bg text-brand-primary">
<Loader2 className="h-8 w-8 animate-spin" />
</div>
);
function App() {
return (
<Suspense fallback={<RouteFallback />}>
<Routes>
<Route path="/login" element={<Login />} />
<Route path="/" element={<Navigate to="/graph" replace />} />
<Route path="/" element={<PrivateRoute><Layout /></PrivateRoute>}>
<Route path="graph" element={<Dashboard />} />
<Route path="products" element={<Products />} />
<Route path="products/groups/:groupKey" element={<ProductGroupDetails />} />
<Route path="products/:id" element={<ProductDetails />} />
<Route path="replenishment" element={<Replenishment />} />
<Route path="cutting" element={<Cutting />} />
<Route path="planning-issues" element={<PlanningIssues />} />
<Route path="supplies" element={<Supplies />} />
<Route path="supplies/:section" element={<Supplies />} />
<Route path="stock" element={<Navigate to="/products" replace />} />
<Route path="stock-alerts" element={<Navigate to="/products" replace />} />
<Route path="production-orders" element={<ProductionOrders />} />
<Route path="production-plans" element={<ProductionPlans />} />
<Route path="production-quality" element={<ProductionQuality />} />
<Route path="clients" element={<Clients />} />
<Route path="clients/:clientToken" element={<ClientDetails />} />
<Route path="sellers" element={<Sellers />} />
<Route path="sellers/:sellerId" element={<SellerDetails />} />
<Route path="rfm" element={<Rfm />} />
<Route path="campaigns" element={<Campaigns />} />
<Route path="registrations" element={<Registrations />} />
<Route path="admin/users" element={<AdminUsers />} />
<Route path="admin/olist" element={<OlistSync />} />
</Route>
</Routes>
</Suspense>
<Routes>
<Route path="/login" element={<Login />} />
<Route path="/" element={<Navigate to="/graph" replace />} />
<Route path="/" element={<PrivateRoute><Layout /></PrivateRoute>}>
<Route path="graph" element={<Dashboard />} />
<Route path="products" element={<Products />} />
<Route path="products/:id" element={<ProductDetails />} />
<Route path="clients" element={<Clients />} />
<Route path="clients/:name" element={<ClientDetails />} />
</Route>
</Routes>
);
}

View File

@@ -1,119 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { DateRange, OrderData } from '../types.ts';
import { buildClientDetailsMetrics } from './clients.ts';
const allPeriod: DateRange = {
start: new Date(2000, 0, 1),
end: new Date(2026, 5, 18, 23, 59, 59, 999)
};
const order = (overrides: Partial<OrderData>): OrderData => ({
Nome_Cliente: 'Cliente',
Data_Pedido: '01-06-2026',
Valor_Pedido: 0,
ID_Produto: '1',
Descricao_Produto: 'Produto',
Quantidade: 1,
Valor_Unitario: 10,
ID_Pedido: 'pedido',
Fone_Cliente: '',
...overrides
});
test('client details do not merge different phone keys that share the same name', () => {
const orders = [
order({
Nome_Cliente: 'Leonardo Barbosa',
Fone_Cliente: '111',
ID_Pedido: 'pedido-1',
Quantidade: 2,
Valor_Unitario: 10
}),
order({
Nome_Cliente: 'Leonardo Barbosa',
Fone_Cliente: '222',
ID_Pedido: 'pedido-2',
Quantidade: 5,
Valor_Unitario: 10
})
];
const metrics = buildClientDetailsMetrics(orders, '111', allPeriod);
assert.equal(metrics.periodOrderCount, 1);
assert.equal(metrics.periodItems, 2);
assert.equal(metrics.periodSpent, 20);
assert.equal(metrics.clientPhone, '111');
});
test('legacy name detail URLs reject ambiguous names instead of merging customers', () => {
const orders = [
order({
Nome_Cliente: 'Leonardo Barbosa',
Fone_Cliente: '111',
ID_Pedido: 'pedido-1'
}),
order({
Nome_Cliente: 'Leonardo Barbosa',
Fone_Cliente: '222',
ID_Pedido: 'pedido-2'
})
];
const metrics = buildClientDetailsMetrics(orders, 'Leonardo Barbosa', allPeriod);
assert.equal(metrics.hasClient, false);
assert.equal(metrics.periodOrderCount, 0);
assert.equal(metrics.periodItems, 0);
});
test('client details merge name variations that share the same phone key', () => {
const orders = [
order({
Nome_Cliente: 'Jose de Matos Dreher',
Fone_Cliente: '333',
ID_Pedido: 'pedido-1',
Valor_Unitario: 10
}),
order({
Nome_Cliente: 'José Matos',
Fone_Cliente: '333',
ID_Pedido: 'pedido-2',
Valor_Unitario: 20
})
];
const metrics = buildClientDetailsMetrics(orders, '333', allPeriod);
assert.equal(metrics.periodOrderCount, 2);
assert.equal(metrics.periodItems, 2);
assert.equal(metrics.periodSpent, 30);
});
test('client detail order totals use the same line-item revenue as summary metrics', () => {
const orders = [
order({
Nome_Cliente: 'Cliente Sem Fone',
ID_Pedido: 'pedido-1',
ID_Produto: 'produto-1',
Valor_Pedido: 100,
Quantidade: 2,
Valor_Unitario: 10
}),
order({
Nome_Cliente: 'Cliente Sem Fone',
ID_Pedido: 'pedido-1',
ID_Produto: 'produto-2',
Valor_Pedido: 100,
Quantidade: 1,
Valor_Unitario: 5
})
];
const metrics = buildClientDetailsMetrics(orders, 'name:Cliente Sem Fone', allPeriod);
assert.equal(metrics.periodSpent, 25);
assert.equal(metrics.groupedOrders[0].orderTotal, 25);
});

View File

@@ -1,297 +0,0 @@
import type { DateRange, OrderData } from '../types.ts';
import { filterOrdersByDateRange, getClientDisplayName, getOrderItemRevenue, getOrderCustomerKey, parseOrderDate } from './orders.ts';
export type ClientSortOption =
| 'recent'
| 'spent_desc'
| 'spent_asc'
| 'ticket_desc'
| 'ticket_asc'
| 'rfm_priority'
| 'items_desc'
| 'items_asc';
export interface ClientSummary {
customerKey: string;
clientToken: string;
name: string;
phone: string;
totalSpent: number;
averageTicket: number;
totalItems: number;
orderCount: number;
lastPurchase: number;
clientType: string;
rfmScore: string;
rfmPriority: number;
}
export interface GroupedClientOrder {
date: string;
orderId: string;
orderTotal: number;
items: OrderData[];
}
export interface ClientDetailsMetrics {
chartData: Array<{
date: string;
value: number;
}>;
purchaseWeekdays: Array<{
label: string;
value: number;
}>;
purchaseHours: Array<{
label: string;
value: number;
}>;
groupedOrders: GroupedClientOrder[];
allTimeOrderCount: number;
clientName: string;
clientPhone: string;
hasClient: boolean;
periodAverageTicket: number;
periodOrderCount: number;
periodSpent: number;
periodItems: number;
}
const WEEKDAY_LABELS = ['Dom', 'Seg', 'Ter', 'Qua', 'Qui', 'Sex', 'Sab'];
const getOrderHour = (order: OrderData): number | null => {
const timestamp = order.Recebido_Em || (/\d{1,2}:\d{2}/.test(order.Data_Pedido || '') ? order.Data_Pedido : '');
if (!timestamp) return null;
const date = new Date(timestamp);
if (!Number.isNaN(date.getTime())) return date.getHours();
const timeMatch = String(timestamp).match(/\b(\d{1,2}):\d{2}/);
if (!timeMatch) return null;
const hour = Number(timeMatch[1]);
return hour >= 0 && hour <= 23 ? hour : null;
};
const scoreTertile = (value: number, values: number[], higherIsBetter = true) => {
const numericValues = values.filter(Number.isFinite);
if (!numericValues.length) return 1;
if (numericValues.length === 1) return 3;
const min = Math.min(...numericValues);
const max = Math.max(...numericValues);
if (min === max) return 2;
const sorted = [...numericValues].sort((a, b) => higherIsBetter ? a - b : b - a);
const index = sorted.findIndex(candidate => candidate === value);
const percentile = index / (sorted.length - 1);
return Math.min(3, Math.max(1, Math.floor(percentile * 3) + 1));
};
const getClientType = (recencyScore: number, valueScore: number) => {
const segmentMap: Record<string, string> = {
'3-3': 'Campeão',
'3-2': 'Potencial Leal',
'3-1': 'Novo Cliente',
'2-3': 'Cliente Leal',
'2-2': 'Precisa de Atenção',
'2-1': 'Quase Dormindo',
'1-3': 'Em Risco',
'1-2': 'Hibernando',
'1-1': 'Perdido'
};
return segmentMap[`${recencyScore}-${valueScore}`] || 'Perdido';
};
const enrichClientsWithRfmType = (
clients: Array<Omit<ClientSummary, 'averageTicket' | 'clientType' | 'rfmScore' | 'rfmPriority'>>,
dateRange: DateRange
): ClientSummary[] => {
const rangeEndTime = dateRange.end.getTime();
const recencyValues = clients.map(client => Math.max(0, Math.floor((rangeEndTime - client.lastPurchase) / 86400000)));
const frequencyValues = clients.map(client => client.orderCount);
const monetaryValues = clients.map(client => client.totalSpent);
return clients.map((client, index) => {
const recencyScore = scoreTertile(recencyValues[index], recencyValues, false);
const frequencyScore = scoreTertile(client.orderCount, frequencyValues);
const monetaryScore = scoreTertile(client.totalSpent, monetaryValues);
const valueScore = Math.min(3, Math.max(1, Math.round((frequencyScore + monetaryScore) / 2)));
const rfmPriority = (recencyScore * 100) + (valueScore * 10) + monetaryScore;
return {
...client,
clientToken: client.clientToken || client.customerKey,
averageTicket: client.orderCount ? client.totalSpent / client.orderCount : 0,
clientType: getClientType(recencyScore, valueScore),
rfmScore: `${recencyScore}${frequencyScore}${monetaryScore}`,
rfmPriority
};
});
};
export const buildClientsSummary = (
ordersData: OrderData[],
dateRange: DateRange,
searchTerm: string,
sortBy: ClientSortOption
): ClientSummary[] => {
const orders = filterOrdersByDateRange(ordersData, dateRange);
const clientMap: Record<string, {
name: string;
totalSpent: number;
totalItems: number;
uniqueOrders: Set<string>;
lastPurchase: number;
phone: string;
}> = {};
orders.forEach(order => {
const clientName = getClientDisplayName(order);
const customerKey = getOrderCustomerKey(order);
if (!clientMap[customerKey]) {
clientMap[customerKey] = { name: clientName, totalSpent: 0, totalItems: 0, uniqueOrders: new Set(), lastPurchase: 0, phone: '' };
}
if (order.Fone_Cliente) {
clientMap[customerKey].phone = order.Fone_Cliente;
}
clientMap[customerKey].totalSpent += getOrderItemRevenue(order);
clientMap[customerKey].totalItems += order.Quantidade;
clientMap[customerKey].uniqueOrders.add(`${order.Data_Pedido}_${order.Valor_Pedido}`);
const orderTime = parseOrderDate(order.Data_Pedido).getTime();
if (orderTime > clientMap[customerKey].lastPurchase) {
clientMap[customerKey].lastPurchase = orderTime;
}
});
const normalizedSearch = searchTerm.trim().toLowerCase();
const clients = enrichClientsWithRfmType(Object.keys(clientMap).map(customerKey => ({
customerKey,
clientToken: customerKey,
name: clientMap[customerKey].name,
phone: clientMap[customerKey].phone,
totalSpent: clientMap[customerKey].totalSpent,
totalItems: clientMap[customerKey].totalItems,
orderCount: clientMap[customerKey].uniqueOrders.size,
lastPurchase: clientMap[customerKey].lastPurchase
})), dateRange);
const filteredClients = normalizedSearch
? clients.filter(client => client.name.toLowerCase().includes(normalizedSearch))
: clients;
return filteredClients.sort((a, b) => {
switch (sortBy) {
case 'recent': return b.lastPurchase - a.lastPurchase;
case 'spent_desc': return b.totalSpent - a.totalSpent;
case 'spent_asc': return a.totalSpent - b.totalSpent;
case 'ticket_desc': return b.averageTicket - a.averageTicket;
case 'ticket_asc': return a.averageTicket - b.averageTicket;
case 'rfm_priority': return b.rfmPriority - a.rfmPriority;
case 'items_desc': return b.totalItems - a.totalItems;
case 'items_asc': return a.totalItems - b.totalItems;
default: return 0;
}
});
};
export const buildClientDetailsMetrics = (ordersData: OrderData[], customerKey: string, dateRange: DateRange): ClientDetailsMetrics => {
const keyedOrders = ordersData.filter(order => getOrderCustomerKey(order) === customerKey);
const legacyNameOrders = keyedOrders.length
? []
: ordersData.filter(order => getClientDisplayName(order) === customerKey);
const legacyCustomerKeys = new Set(legacyNameOrders.map(getOrderCustomerKey));
const resolvedLegacyKey = legacyCustomerKeys.size === 1 ? [...legacyCustomerKeys][0] : '';
const clientOrders = keyedOrders.length
? keyedOrders
: resolvedLegacyKey
? ordersData.filter(order => getOrderCustomerKey(order) === resolvedLegacyKey)
: [];
const periodOrders = filterOrdersByDateRange(clientOrders, dateRange);
const groupedOrdersMap: Record<string, GroupedClientOrder> = {};
const spentByDate: Record<string, number> = {};
let clientPhone = '';
let clientName = '';
let periodSpent = 0;
let periodItems = 0;
clientOrders.forEach(order => {
if (order.Fone_Cliente && !clientPhone) clientPhone = order.Fone_Cliente;
if (!clientName) clientName = getClientDisplayName(order);
});
periodOrders.forEach(order => {
periodSpent += getOrderItemRevenue(order);
periodItems += order.Quantidade;
spentByDate[order.Data_Pedido] = (spentByDate[order.Data_Pedido] || 0) + getOrderItemRevenue(order);
const key = order.ID_Pedido || `${order.Data_Pedido}_${order.Valor_Pedido}`;
if (!groupedOrdersMap[key]) {
groupedOrdersMap[key] = {
date: order.Data_Pedido,
orderId: order.ID_Pedido || key,
orderTotal: 0,
items: []
};
}
groupedOrdersMap[key].items.push(order);
groupedOrdersMap[key].orderTotal += getOrderItemRevenue(order);
});
const groupedOrders = Object.values(groupedOrdersMap).sort((a, b) => {
return parseOrderDate(b.date).getTime() - parseOrderDate(a.date).getTime();
});
const allTimeOrderIds = new Set(clientOrders.map(order => order.ID_Pedido || `${order.Data_Pedido}_${order.Valor_Pedido}`));
const periodOrderCount = groupedOrders.length;
const chartData = Object.keys(spentByDate).map(date => ({
date,
value: spentByDate[date]
})).sort((a, b) => parseOrderDate(a.date).getTime() - parseOrderDate(b.date).getTime());
const weekdayCounts = WEEKDAY_LABELS.map(label => ({ label, value: 0 }));
const hourCounts = Array.from({ length: 24 }, (_, hour) => ({
label: `${String(hour).padStart(2, '0')}h`,
value: 0
}));
const patternOrders = new Map<string, OrderData>();
clientOrders.forEach(order => {
const key = order.ID_Pedido || `${order.Data_Pedido}_${order.Valor_Pedido}`;
if (!patternOrders.has(key)) {
patternOrders.set(key, order);
}
});
patternOrders.forEach(order => {
const orderDate = parseOrderDate(order.Data_Pedido);
if (!Number.isNaN(orderDate.getTime())) {
weekdayCounts[orderDate.getDay()].value += 1;
}
const orderHour = getOrderHour(order);
if (orderHour !== null) {
hourCounts[orderHour].value += 1;
}
});
return {
chartData,
purchaseWeekdays: weekdayCounts,
purchaseHours: hourCounts,
groupedOrders,
allTimeOrderCount: allTimeOrderIds.size,
clientName,
clientPhone,
hasClient: clientOrders.length > 0,
periodAverageTicket: periodOrderCount ? periodSpent / periodOrderCount : 0,
periodOrderCount,
periodSpent,
periodItems
};
};

View File

@@ -1,179 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { buildCutPlan, buildOpenProductionByProductId, classifyCutFamily } from './cutting.ts';
import type { DateRange, ProductAnalyticsItem, ProductionOrderItem } from '../types.ts';
const range: DateRange = {
start: new Date(2026, 6, 1),
end: new Date(2026, 6, 7)
};
const product = (overrides: Partial<ProductAnalyticsItem>): ProductAnalyticsItem => ({
id: 'SKU-1',
name: 'BASE LISA CAMISETA COR PRETO TAMANHO - M',
quantitySold: 70,
revenue: 700,
orderLineCount: 10,
lastPrice: 10,
stock: 20,
firstSaleDate: '2026-07-01',
lastSaleDate: '2026-07-07',
...overrides
});
const productionOrder = (overrides: Partial<ProductionOrderItem>): ProductionOrderItem => ({
id: 1,
tinyId: '',
number: 'OP-1',
status: 'in_progress',
statusLabel: 'Em andamento',
orderReference: '',
issueDate: '2026-07-01',
expectedDate: '2026-07-15',
productSku: '',
productDescription: 'BASE LISA CAMISETA COR PRETO TAMANHO - M',
quantity: 40,
unit: 'UN',
integrationStatus: '',
markers: [],
createdAt: null,
updatedAt: null,
...overrides
});
test('classifyCutFamily maps known product bases to internal cut families', () => {
assert.equal(classifyCutFamily('BASE LISA CAMISETA').key, 'BLCS');
assert.equal(classifyCutFamily('BASE LISA CAMISETA OVER').key, 'BLOS');
assert.equal(classifyCutFamily('BASE LISA MOLETOM CANGURU').key, 'BLMC');
assert.equal(classifyCutFamily('BASE LISA CAMISETA PIMA').key, 'BLPM');
assert.equal(classifyCutFamily('BASE LISA REGATA MACHAO').key, 'BLRM');
assert.equal(classifyCutFamily('BONÉ').key, 'OUTROS');
});
test('buildCutPlan calculates projected cut need from sales pace and stock', () => {
const plan = buildCutPlan([product({})], range, 14);
const [row] = plan.needRows;
assert.equal(row.dailySales, 10);
assert.equal(row.projectedDemand, 140);
assert.equal(row.availableQuantity, 20);
assert.equal(row.suggestedCutQuantity, 120);
assert.equal(row.estimatedRolls, null);
assert.deepEqual(row.issues, ['missing_yield_rule']);
assert.equal(plan.summary.suggestedCutQuantity, 120);
});
test('buildCutPlan subtracts open production quantity from suggested cut need', () => {
const plan = buildCutPlan([product({ id: 'SKU-1' })], range, 14, { 'SKU-1': 100 });
const [row] = plan.needRows;
assert.equal(row.availableQuantity, 120);
assert.equal(row.suggestedCutQuantity, 20);
});
test('buildCutPlan estimates rolls when a family yield is configured', () => {
const plan = buildCutPlan([product({})], range, 14, {}, { familyYields: { BLCS: 50 } });
const [row] = plan.needRows;
assert.equal(row.suggestedCutQuantity, 120);
assert.equal(row.estimatedRolls, 3);
assert.deepEqual(row.issues, []);
assert.equal(plan.summary.estimatedRolls, 3);
});
test('buildCutPlan applies per-product planning overrides to finished apparel', () => {
const plan = buildCutPlan([
product({
id: 'SKU-2',
name: 'CAMISETA SEM PADRAO',
quantitySold: 70,
stock: 0
})
], range, 7, {}, {
familyYields: { BLCS: 35 },
productOverrides: {
'SKU-2': { familyKey: 'BLCS', color: 'Preto', size: 'M' }
}
});
assert.equal(plan.needRows[0].family.key, 'BLCS');
assert.equal(plan.needRows[0].color, 'Preto');
assert.equal(plan.needRows[0].size, 'M');
assert.equal(plan.needRows[0].estimatedRolls, 2);
assert.deepEqual(plan.needRows[0].issues, []);
});
test('buildCutPlan excludes non-apparel products from cut planning', () => {
const plan = buildCutPlan([
product({
id: 'SKU-2',
name: 'BONÉ PRETO',
quantitySold: 70,
stock: 0
}),
product({
id: 'SKU-3',
name: 'TRANSPARENTE PP MILHEIRO - 25x35',
quantitySold: 70,
stock: 0
})
], range, 7);
assert.equal(plan.rows.length, 0);
assert.equal(plan.summary.skuCount, 0);
});
test('buildCutPlan includes non-apparel products manually marked as finished apparel', () => {
const plan = buildCutPlan([
product({
id: 'SKU-2',
name: 'BONÉ PRETO',
quantitySold: 70,
stock: 0
})
], range, 7, {}, {
familyYields: { BLCS: 35 },
productOverrides: {
'SKU-2': {
productType: 'finished_apparel',
familyKey: 'BLCS',
color: 'Preto',
size: 'M'
}
}
});
assert.equal(plan.needRows.length, 1);
assert.equal(plan.needRows[0].family.key, 'BLCS');
assert.equal(plan.needRows[0].suggestedCutQuantity, 70);
});
test('buildOpenProductionByProductId matches open OPs by SKU and normalized product variant', () => {
const products = [
product({ id: 'SKU-1' }),
product({ id: 'SKU-2', name: 'BASE LISA CAMISETA COR BRANCO TAMANHO - G' })
];
const totals = buildOpenProductionByProductId(products, [
productionOrder({ productSku: 'SKU-1', productDescription: '', quantity: 10 }),
productionOrder({ productSku: '', productDescription: 'Base Lisa Camiseta Cor Branco Tamanho - G', quantity: 20 }),
productionOrder({ productSku: 'SKU-1', status: 'finished', quantity: 999 })
]);
assert.deepEqual(totals, { 'SKU-1': 10, 'SKU-2': 20 });
});
test('buildCutPlan marks apparel products that cannot be planned cleanly for cutting', () => {
const plan = buildCutPlan([
product({
id: 'SKU-2',
name: 'VESTUARIO SEM PADRAO',
quantitySold: 70,
stock: 0
})
], range, 7);
assert.equal(plan.needRows[0].family.key, 'OUTROS');
assert.deepEqual(plan.needRows[0].issues, ['missing_family_rule', 'missing_color', 'missing_size']);
assert.equal(plan.summary.rowsWithIssues, 1);
});

View File

@@ -1,313 +0,0 @@
import type { CutFamilyKey, CutProductOverride, DateRange, ProductAnalyticsItem, ProductionOrderItem } from '../types';
import { normalizeProductText, parseProductName, sortProductSizes } from '../productParsing.ts';
import { resolveProductType } from '../productClassification.ts';
import { getPlanningStock } from '../planningStock.ts';
export type { CutFamilyKey, CutProductOverride };
export type CutIssue = 'missing_family_rule' | 'missing_color' | 'missing_size' | 'missing_yield_rule';
export interface CutFamilyRule {
key: CutFamilyKey;
label: string;
materialLabel: string;
keywords: string[];
unitsPerRoll: number | null;
}
export interface CutPlanOptions {
familyYields?: Partial<Record<CutFamilyKey, number>>;
productOverrides?: Record<string, CutProductOverride>;
}
export interface CutPlanSkuRow extends ProductAnalyticsItem {
family: CutFamilyRule;
baseName: string;
color: string;
size: string;
dailySales: number;
projectedDemand: number;
openProductionQuantity: number;
availableQuantity: number;
suggestedCutQuantity: number;
estimatedRolls: number | null;
daysOfCover: number | null;
issues: CutIssue[];
}
export interface CutPlanFamilySummary {
family: CutFamilyRule;
skuCount: number;
colorCount: number;
sizeCount: number;
quantitySold: number;
stock: number;
projectedDemand: number;
suggestedCutQuantity: number;
estimatedRolls: number | null;
}
export interface CutPlanSummary {
skuCount: number;
familiesWithNeed: number;
colorsWithNeed: number;
sizesWithNeed: number;
totalSold: number;
totalStock: number;
projectedDemand: number;
suggestedCutQuantity: number;
estimatedRolls: number | null;
rowsWithIssues: number;
openProductionQuantity: number;
}
export interface CutPlan {
rows: CutPlanSkuRow[];
needRows: CutPlanSkuRow[];
familySummaries: CutPlanFamilySummary[];
summary: CutPlanSummary;
}
export const CUT_FAMILY_RULES: CutFamilyRule[] = [
{
key: 'BLMC',
label: 'Moletom canguru',
materialLabel: 'BLMC',
keywords: ['MOLETOM CANGURU', 'MOLETOM'],
unitsPerRoll: null
},
{
key: 'BLOS',
label: 'Camiseta over',
materialLabel: 'BLOS',
keywords: ['OVER'],
unitsPerRoll: null
},
{
key: 'BLPM',
label: 'Camiseta Pima',
materialLabel: 'BLPM',
keywords: ['CAMISETA PIMA', 'PIMA'],
unitsPerRoll: null
},
{
key: 'BLRM',
label: 'Regata',
materialLabel: 'BLRM',
keywords: ['REGATA'],
unitsPerRoll: null
},
{
key: 'BLCS',
label: 'Camiseta regular',
materialLabel: 'BLCS',
keywords: ['CAMISETA'],
unitsPerRoll: null
}
];
export const OUTROS_RULE: CutFamilyRule = {
key: 'OUTROS',
label: 'Sem regra',
materialLabel: 'Pendente',
keywords: [],
unitsPerRoll: null
};
const FAMILY_RULES_BY_KEY = new Map<CutFamilyKey, CutFamilyRule>([
...CUT_FAMILY_RULES.map(rule => [rule.key, rule] as const),
[OUTROS_RULE.key, OUTROS_RULE]
]);
export const getRangeDays = (range: DateRange) => {
const start = new Date(range.start);
const end = new Date(range.end);
start.setHours(0, 0, 0, 0);
end.setHours(0, 0, 0, 0);
return Math.max(1, Math.round((end.getTime() - start.getTime()) / 86_400_000) + 1);
};
const normalizeRuleText = (value: string) => (
value
.normalize('NFD')
.replace(/\p{Diacritic}/gu, '')
.toUpperCase()
);
export const classifyCutFamily = (baseName: string): CutFamilyRule => {
const normalizedName = normalizeRuleText(baseName);
return CUT_FAMILY_RULES.find(rule => (
rule.keywords.some(keyword => normalizedName.includes(normalizeRuleText(keyword)))
)) || OUTROS_RULE;
};
const ruleWithConfiguredYield = (rule: CutFamilyRule, familyYields?: Partial<Record<CutFamilyKey, number>>): CutFamilyRule => {
const configuredYield = familyYields?.[rule.key];
return {
...rule,
unitsPerRoll: configuredYield && configuredYield > 0 ? configuredYield : rule.unitsPerRoll
};
};
const normalizeMatchText = (value: string) => normalizeProductText(value)
.normalize('NFD')
.replace(/\p{Diacritic}/gu, '')
.toLowerCase();
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', 'em corte', 'em costura', 'em revisao'].includes(normalizeMatchText(order.status))
);
export const buildOpenProductionByProductId = (
products: ProductAnalyticsItem[],
productionOrders: ProductionOrderItem[]
): Record<string, number> => {
const productById = new Map(products.map(product => [product.id, product]));
const productByName = new Map(products.map(product => [normalizeMatchText(product.name), product]));
const productsByVariantKey = new Map<string, ProductAnalyticsItem>();
products.forEach(product => {
const metadata = parseProductName(product.name);
const key = [
normalizeMatchText(metadata.baseName),
normalizeMatchText(metadata.color),
normalizeMatchText(metadata.size)
].join('::');
productsByVariantKey.set(key, product);
});
return productionOrders.reduce<Record<string, number>>((totals, order) => {
if (!isOpenProductionOrder(order)) return totals;
const skuMatch = order.productSku ? productById.get(order.productSku) : undefined;
const nameMatch = order.productDescription ? productByName.get(normalizeMatchText(order.productDescription)) : undefined;
let product = skuMatch || nameMatch;
if (!product && order.productDescription) {
const metadata = parseProductName(order.productDescription);
const key = [
normalizeMatchText(metadata.baseName),
normalizeMatchText(metadata.color),
normalizeMatchText(metadata.size)
].join('::');
product = productsByVariantKey.get(key);
}
if (!product) return totals;
totals[product.id] = (totals[product.id] || 0) + order.quantity;
return totals;
}, {});
};
export const buildCutPlan = (
products: ProductAnalyticsItem[],
dateRange: DateRange,
targetCoverageDays: number,
openProductionByProductId: Record<string, number> = {},
options: CutPlanOptions = {}
): CutPlan => {
const rangeDays = getRangeDays(dateRange);
const cuttableProducts = products.filter(product => (
resolveProductType(product.name, options.productOverrides?.[product.id]) === 'finished_apparel'
));
const rows = cuttableProducts.map<CutPlanSkuRow>(product => {
const metadata = parseProductName(product.name);
const override = options.productOverrides?.[product.id];
const overrideRule = override?.familyKey ? FAMILY_RULES_BY_KEY.get(override.familyKey) : undefined;
const family = ruleWithConfiguredYield(overrideRule || classifyCutFamily(metadata.baseName), options.familyYields);
const color = normalizeProductText(override?.color || metadata.color);
const size = normalizeProductText(override?.size || metadata.size).toUpperCase();
const dailySales = product.quantitySold / rangeDays;
const projectedDemand = dailySales * targetCoverageDays;
const openProductionQuantity = openProductionByProductId[product.id] || 0;
const availableQuantity = getPlanningStock(product.stock) + openProductionQuantity;
const suggestedCutQuantity = Math.max(0, Math.ceil(projectedDemand - availableQuantity));
const estimatedRolls = family.unitsPerRoll && suggestedCutQuantity > 0
? Math.ceil(suggestedCutQuantity / family.unitsPerRoll)
: null;
const daysOfCover = dailySales > 0 ? availableQuantity / dailySales : null;
const issues: CutIssue[] = [];
if (family.key === 'OUTROS') issues.push('missing_family_rule');
if (!color) issues.push('missing_color');
if (!size) issues.push('missing_size');
if (suggestedCutQuantity > 0 && family.key !== 'OUTROS' && !family.unitsPerRoll) issues.push('missing_yield_rule');
return {
...product,
family,
baseName: metadata.baseName,
color,
size,
dailySales,
projectedDemand,
openProductionQuantity,
availableQuantity,
suggestedCutQuantity,
estimatedRolls,
daysOfCover,
issues
};
});
const needRows = rows.filter(row => row.suggestedCutQuantity > 0);
const familyGroups = new Map<CutFamilyKey, CutPlanSkuRow[]>();
needRows.forEach(row => {
const group = familyGroups.get(row.family.key) || [];
group.push(row);
familyGroups.set(row.family.key, group);
});
const familySummaries = Array.from(familyGroups.values())
.map<CutPlanFamilySummary>(group => {
const first = group[0];
const colors = new Set(group.map(row => row.color).filter(Boolean));
const sizes = sortProductSizes(Array.from(new Set(group.map(row => row.size).filter(Boolean))));
return {
family: first.family,
skuCount: group.length,
colorCount: colors.size,
sizeCount: sizes.length,
quantitySold: group.reduce((total, row) => total + row.quantitySold, 0),
stock: group.reduce((total, row) => total + row.stock, 0),
projectedDemand: group.reduce((total, row) => total + row.projectedDemand, 0),
suggestedCutQuantity: group.reduce((total, row) => total + row.suggestedCutQuantity, 0),
estimatedRolls: group.some(row => row.estimatedRolls === null)
? null
: group.reduce((total, row) => total + (row.estimatedRolls || 0), 0)
};
})
.sort((a, b) => b.suggestedCutQuantity - a.suggestedCutQuantity);
const colorsWithNeed = new Set(needRows.map(row => row.color).filter(Boolean));
const sizesWithNeed = new Set(needRows.map(row => row.size).filter(Boolean));
return {
rows,
needRows,
familySummaries,
summary: {
skuCount: needRows.length,
familiesWithNeed: familySummaries.length,
colorsWithNeed: colorsWithNeed.size,
sizesWithNeed: sizesWithNeed.size,
totalSold: needRows.reduce((total, row) => total + row.quantitySold, 0),
totalStock: needRows.reduce((total, row) => total + row.stock, 0),
projectedDemand: needRows.reduce((total, row) => total + row.projectedDemand, 0),
suggestedCutQuantity: needRows.reduce((total, row) => total + row.suggestedCutQuantity, 0),
estimatedRolls: needRows.some(row => row.estimatedRolls === null)
? null
: needRows.reduce((total, row) => total + (row.estimatedRolls || 0), 0),
rowsWithIssues: needRows.filter(row => row.issues.length > 0).length,
openProductionQuantity: needRows.reduce((total, row) => total + row.openProductionQuantity, 0)
}
};
};

View File

@@ -1,239 +0,0 @@
import type { DashboardAnalytics, DateRange, OrderData } from '../types';
import { filterOrdersByDateRange, getBaseProductName, getOrderItemRevenue, parseOrderDate } from './orders';
import { formatDisplayName, removeTrailingSellerId } from '../displayFormatters';
import { normalizeUnknownLabel } from '../chartUtils';
const COLORS = [
'#25C2FF', '#18D6B5', '#A06BFF', '#FF6B8A', '#FFC247',
'#70E083', '#6EA5FF', '#FF6EC7', '#FF8A63', '#22C7E6',
'#B8E84D', '#FFA24A', '#B992FF', '#38E0C4', '#FFD166',
'#FF7A9B', '#82B8FF', '#52DFA0', '#D28BFF', '#F7B23B'
];
const globalColorMap: Record<string, string> = {};
let globalColorIndex = 0;
const getProductColor = (name: string): string => {
if (!globalColorMap[name]) {
globalColorMap[name] = COLORS[globalColorIndex % COLORS.length];
globalColorIndex += 1;
}
return globalColorMap[name];
};
const formatSellerDisplayName = (value: string) => (
normalizeUnknownLabel(formatDisplayName(removeTrailingSellerId(value)), 'Sem vendedor')
);
export interface ChartProductMetric {
name: string;
id: string;
value: number;
fill: string;
}
export interface SellerDateRevenueMetric extends ChartProductMetric {
date: string;
orders: number;
}
export interface SellerHourRevenueMetric extends ChartProductMetric {
hour: number;
orders: number;
}
export interface DashboardMetrics {
totalRevenue: number;
totalOrders: number;
averageOrderValue: number;
salesByProduct: ChartProductMetric[];
revenueByProduct: ChartProductMetric[];
revenueBySeller: ChartProductMetric[];
ordersBySeller: ChartProductMetric[];
sellerRevenueByDate: SellerDateRevenueMetric[];
sellerRevenueByHour: SellerHourRevenueMetric[];
}
export const applyDashboardColors = (metrics: DashboardAnalytics): DashboardMetrics => {
const displayProducts = Array.from(new Set([
...metrics.salesByProduct.map(product => product.name),
...metrics.revenueByProduct.map(product => product.name),
...(metrics.revenueBySeller || []).map(seller => seller.name),
...(metrics.ordersBySeller || []).map(seller => seller.name),
...(metrics.sellerRevenueByDate || []).map(seller => seller.name),
...(metrics.sellerRevenueByHour || []).map(seller => seller.name)
])).sort();
const productColors = displayProducts.reduce<Record<string, string>>((colors, name) => {
colors[name] = getProductColor(name);
return colors;
}, {});
return {
totalRevenue: metrics.totalRevenue,
totalOrders: metrics.totalOrders,
averageOrderValue: metrics.averageOrderValue,
salesByProduct: metrics.salesByProduct.map(product => ({
...product,
fill: productColors[product.name]
})),
revenueByProduct: metrics.revenueByProduct.map(product => ({
...product,
fill: productColors[product.name]
})),
revenueBySeller: (metrics.revenueBySeller || []).map(seller => ({
...seller,
name: formatSellerDisplayName(seller.name),
fill: productColors[seller.name]
})),
ordersBySeller: (metrics.ordersBySeller || []).map(seller => ({
...seller,
name: formatSellerDisplayName(seller.name),
fill: productColors[seller.name]
})),
sellerRevenueByDate: (metrics.sellerRevenueByDate || []).map(seller => ({
...seller,
name: formatSellerDisplayName(seller.name),
orders: seller.orders || 0,
fill: productColors[seller.name]
})),
sellerRevenueByHour: (metrics.sellerRevenueByHour || []).map(seller => ({
...seller,
name: formatSellerDisplayName(seller.name),
orders: seller.orders || 0,
fill: productColors[seller.name]
}))
};
};
export const buildDashboardMetrics = (ordersData: OrderData[], dateRange: DateRange): DashboardMetrics => {
const filteredData = filterOrdersByDateRange(ordersData, dateRange);
let revenue = 0;
let totalItems = 0;
const productSalesMap: Record<string, number> = {};
const productRevenueMap: Record<string, number> = {};
const productNameIdMap: Record<string, string> = {};
const sellerRevenueMap: Record<string, number> = {};
const sellerRevenueByDateMap: Record<string, Record<string, number>> = {};
const sellerRevenueByHourMap: Record<string, Record<number, number>> = {};
const sellerOrderKeysByDateMap: Record<string, Record<string, Set<string>>> = {};
const sellerOrderKeysByHourMap: Record<string, Record<number, Set<string>>> = {};
const sellerOrderKeysMap: Record<string, Set<string>> = {};
const sellerIdMap: Record<string, string> = {};
filteredData.forEach(order => {
const itemRevenue = getOrderItemRevenue(order);
const productName = getBaseProductName(order.Descricao_Produto);
const sellerName = formatSellerDisplayName(order.nome_vendedor || '');
const sellerId = order.id_vendedor || sellerName;
const orderKey = order.ID_Pedido || `${order.Nome_Cliente}_${order.Data_Pedido}_${order.Valor_Pedido}`;
revenue += itemRevenue;
totalItems += order.Quantidade;
productNameIdMap[productName] = order.ID_Produto;
productSalesMap[productName] = (productSalesMap[productName] || 0) + order.Quantidade;
productRevenueMap[productName] = (productRevenueMap[productName] || 0) + itemRevenue;
if (sellerName) {
const parsedOrderDate = parseOrderDate(order.Data_Pedido);
const dateKey = Number.isNaN(parsedOrderDate.getTime())
? order.Data_Pedido
: parsedOrderDate.toISOString().slice(0, 10);
const orderTimestamp = order.Recebido_Em ? new Date(order.Recebido_Em) : null;
const orderHour = orderTimestamp && !Number.isNaN(orderTimestamp.getTime()) ? orderTimestamp.getHours() : null;
sellerIdMap[sellerName] = sellerId;
sellerRevenueMap[sellerName] = (sellerRevenueMap[sellerName] || 0) + itemRevenue;
if (!sellerRevenueByDateMap[sellerName]) sellerRevenueByDateMap[sellerName] = {};
sellerRevenueByDateMap[sellerName][dateKey] = (sellerRevenueByDateMap[sellerName][dateKey] || 0) + itemRevenue;
if (!sellerOrderKeysByDateMap[sellerName]) sellerOrderKeysByDateMap[sellerName] = {};
if (!sellerOrderKeysByDateMap[sellerName][dateKey]) sellerOrderKeysByDateMap[sellerName][dateKey] = new Set();
sellerOrderKeysByDateMap[sellerName][dateKey].add(orderKey);
if (orderHour !== null) {
if (!sellerRevenueByHourMap[sellerName]) sellerRevenueByHourMap[sellerName] = {};
sellerRevenueByHourMap[sellerName][orderHour] = (sellerRevenueByHourMap[sellerName][orderHour] || 0) + itemRevenue;
if (!sellerOrderKeysByHourMap[sellerName]) sellerOrderKeysByHourMap[sellerName] = {};
if (!sellerOrderKeysByHourMap[sellerName][orderHour]) sellerOrderKeysByHourMap[sellerName][orderHour] = new Set();
sellerOrderKeysByHourMap[sellerName][orderHour].add(orderKey);
}
if (!sellerOrderKeysMap[sellerName]) sellerOrderKeysMap[sellerName] = new Set();
sellerOrderKeysMap[sellerName].add(orderKey);
}
});
const topSalesNames = Object.keys(productSalesMap).sort((a, b) => productSalesMap[b] - productSalesMap[a]).slice(0, 10);
const topRevenueNames = Object.keys(productRevenueMap).sort((a, b) => productRevenueMap[b] - productRevenueMap[a]).slice(0, 10);
const topSellerRevenueNames = Object.keys(sellerRevenueMap).sort((a, b) => sellerRevenueMap[b] - sellerRevenueMap[a]).slice(0, 10);
const topSellerOrderNames = Object.keys(sellerOrderKeysMap).sort((a, b) => sellerOrderKeysMap[b].size - sellerOrderKeysMap[a].size).slice(0, 10);
const displayProducts = Array.from(new Set([...topSalesNames, ...topRevenueNames])).sort();
const productColors = displayProducts.reduce<Record<string, string>>((colors, name) => {
colors[name] = getProductColor(name);
return colors;
}, {});
const salesByProduct = topSalesNames.map(name => ({
name,
id: productNameIdMap[name],
value: productSalesMap[name],
fill: productColors[name]
}));
const revenueByProduct = topRevenueNames.map(name => ({
name,
id: productNameIdMap[name],
value: productRevenueMap[name],
fill: productColors[name]
}));
const revenueBySeller = topSellerRevenueNames.map(name => ({
name,
id: sellerIdMap[name],
value: sellerRevenueMap[name],
fill: getProductColor(name)
}));
const ordersBySeller = topSellerOrderNames.map(name => ({
name,
id: sellerIdMap[name],
value: sellerOrderKeysMap[name].size,
fill: getProductColor(name)
}));
const sellerRevenueByDate = topSellerRevenueNames.flatMap(name => (
Object.keys(sellerRevenueByDateMap[name] || {})
.sort()
.map(date => ({
name,
id: sellerIdMap[name],
date,
value: sellerRevenueByDateMap[name][date],
orders: sellerOrderKeysByDateMap[name]?.[date]?.size || 0,
fill: getProductColor(name)
}))
));
const sellerRevenueByHour = topSellerRevenueNames.flatMap(name => (
Object.keys(sellerRevenueByHourMap[name] || {})
.map(Number)
.sort((a, b) => a - b)
.map(hour => ({
name,
id: sellerIdMap[name],
hour,
value: sellerRevenueByHourMap[name][hour],
orders: sellerOrderKeysByHourMap[name]?.[hour]?.size || 0,
fill: getProductColor(name)
}))
));
return {
totalRevenue: revenue,
totalOrders: totalItems,
averageOrderValue: revenue / (filteredData.length || 1),
salesByProduct,
revenueByProduct,
revenueBySeller,
ordersBySeller,
sellerRevenueByDate,
sellerRevenueByHour
};
};

View File

@@ -1,47 +0,0 @@
import type { DateRange, OrderData } from '../types.ts';
const SIZE_SUFFIX_PATTERN = /\s+-\s+(?:(?:PP|P|M|G|GG|XG|XGG|EG|EGG|EXG|U|UNICO|ÚNICO|\d{2})(?:\/(?:PP|P|M|G|GG|XG|XGG|EG|EGG|EXG|U|UNICO|ÚNICO|\d{2}))*)$/i;
export const parseOrderDate = (dateStr: string): Date => {
if (!dateStr) return new Date(0);
if (dateStr.includes('T')) return new Date(dateStr);
const parts = dateStr.split(/[-/]/);
if (parts.length === 3) {
if (parts[0].length === 4) {
return new Date(Number(parts[0]), Number(parts[1]) - 1, Number(parts[2]));
}
return new Date(Number(parts[2]), Number(parts[1]) - 1, Number(parts[0]));
}
const fallback = new Date(dateStr);
return Number.isNaN(fallback.getTime()) ? new Date(0) : fallback;
};
export const getBaseProductName = (description: string): string => {
const productName = description.trim();
if (productName.toLocaleUpperCase('pt-BR').startsWith('ETIQUETA')) {
return productName;
}
return productName.split(' TAMANHO')[0].replace(SIZE_SUFFIX_PATTERN, '').trim();
};
export const getClientDisplayName = (order: OrderData): string => {
return order.Nome_Cliente || 'Cliente Desconhecido';
};
export const getOrderCustomerKey = (order: OrderData): string => {
return order.Fone_Cliente || `name:${getClientDisplayName(order)}`;
};
export const isOrderInDateRange = (order: OrderData, dateRange: DateRange): boolean => {
const orderDate = parseOrderDate(order.Data_Pedido);
return orderDate >= dateRange.start && orderDate <= dateRange.end;
};
export const filterOrdersByDateRange = (orders: OrderData[], dateRange: DateRange): OrderData[] => {
return orders.filter(order => isOrderInDateRange(order, dateRange));
};
export const getOrderItemRevenue = (order: OrderData): number => {
return order.Quantidade * order.Valor_Unitario;
};

View File

@@ -1,117 +0,0 @@
import type { DateRange, OrderData, StockData } from '../types';
import { getBaseProductName, getOrderItemRevenue, isOrderInDateRange } from './orders';
export interface ProductSummary {
id: string;
name: string;
totalSold: number;
revenue: number;
lastPrice: number;
stock: number;
}
export interface ProductDetailsMetrics {
productInfo: {
id: string;
name: string;
price: number;
} | null;
chartData: Array<{
date: string;
value: number;
}>;
totalSold: number;
totalRevenue: number;
}
export const buildProductsSummary = (
ordersData: OrderData[],
stockData: StockData[],
dateRange: DateRange,
searchTerm: string
): ProductSummary[] => {
const productMap: Record<string, ProductSummary> = {};
stockData.forEach(item => {
productMap[item.produto_id] = {
id: item.produto_id,
name: item.nome,
totalSold: 0,
revenue: 0,
lastPrice: 0,
stock: item.saldo || 0
};
});
ordersData.forEach(order => {
if (!isOrderInDateRange(order, dateRange)) return;
if (!productMap[order.ID_Produto]) {
productMap[order.ID_Produto] = {
id: order.ID_Produto,
name: getBaseProductName(order.Descricao_Produto),
totalSold: 0,
revenue: 0,
lastPrice: order.Valor_Unitario,
stock: 0
};
}
if (productMap[order.ID_Produto].name === 'Unknown' || !productMap[order.ID_Produto].name) {
productMap[order.ID_Produto].name = getBaseProductName(order.Descricao_Produto);
}
productMap[order.ID_Produto].totalSold += order.Quantidade;
productMap[order.ID_Produto].revenue += getOrderItemRevenue(order);
productMap[order.ID_Produto].lastPrice = order.Valor_Unitario;
});
const normalizedSearch = searchTerm.trim().toLowerCase();
const products = Object.values(productMap);
const filteredProducts = normalizedSearch
? products.filter(product => product.name.toLowerCase().includes(normalizedSearch) || product.id.includes(searchTerm))
: products;
return filteredProducts.sort((a, b) => b.totalSold - a.totalSold);
};
export const buildProductDetailsMetrics = (
ordersData: OrderData[],
productId: string | undefined,
dateRange: DateRange
): ProductDetailsMetrics => {
const productOrders = ordersData.filter(order => order.ID_Produto === productId);
if (productOrders.length === 0) {
return { productInfo: null, chartData: [], totalSold: 0, totalRevenue: 0 };
}
const productInfo = {
id: productOrders[0].ID_Produto,
name: getBaseProductName(productOrders[0].Descricao_Produto),
price: productOrders[0].Valor_Unitario
};
const salesByDate: Record<string, number> = {};
let totalSold = 0;
let totalRevenue = 0;
productOrders.forEach(order => {
if (!isOrderInDateRange(order, dateRange)) return;
salesByDate[order.Data_Pedido] = (salesByDate[order.Data_Pedido] || 0) + order.Quantidade;
totalSold += order.Quantidade;
totalRevenue += getOrderItemRevenue(order);
});
const chartData = Object.keys(salesByDate).map(date => ({
date,
value: salesByDate[date]
})).sort((a, b) => {
const [da, ma, ya] = a.date.split('-').map(Number);
const [db, mb, yb] = b.date.split('-').map(Number);
return new Date(ya, ma - 1, da).getTime() - new Date(yb, mb - 1, db).getTime();
});
return { productInfo, chartData, totalSold, totalRevenue };
};

BIN
src/assets/hero.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

1
src/assets/react.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

1
src/assets/vite.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

View File

@@ -1,28 +0,0 @@
type SkuEditParams = {
sku: string;
name?: string;
color?: string;
size?: string;
type?: 'finished_product' | 'raw_material';
};
export const buildSkuEditPath = ({ sku, name = '', color = '', size = '', type }: SkuEditParams) => {
const params = new URLSearchParams({ tab: 'products', sku });
if (name) params.set('name', name);
if (color) params.set('color', color);
if (size) params.set('size', size);
if (type) params.set('type', type);
return `/registrations?${params.toString()}`;
};
export const buildCuttingSkuConfigPath = ({ sku }: Pick<SkuEditParams, 'sku'>) => {
const params = new URLSearchParams({ config: 'corrections', sku });
return `/cutting?${params.toString()}`;
};
export const buildConsumptionReferencePath = ({ sku, name = '', color = '' }: Omit<SkuEditParams, 'size'>) => {
const params = new URLSearchParams({ tab: 'references', sku });
if (name) params.set('name', name);
if (color) params.set('color', color);
return `/registrations?${params.toString()}`;
};

View File

@@ -1,125 +0,0 @@
import type { DateRange } from './types';
export type DateBucket = 'day' | 'week' | 'month';
const MS_PER_DAY = 24 * 60 * 60 * 1000;
export const getRangeDayCount = (dateRange: DateRange) => (
Math.max(1, Math.ceil((dateRange.end.getTime() - dateRange.start.getTime()) / MS_PER_DAY) + 1)
);
export const getAutoDateBucket = (dateRange: DateRange): DateBucket => {
const days = getRangeDayCount(dateRange);
if (days > 365) return 'month';
if (days > 90) return 'week';
return 'day';
};
export const parseChartDate = (value: string): Date | null => {
if (!value) return null;
const isoMatch = value.match(/^(\d{4})-(\d{2})-(\d{2})$/);
if (isoMatch) {
const [, year, month, day] = isoMatch;
return new Date(Number(year), Number(month) - 1, Number(day));
}
const localMatch = value.match(/^(\d{2})\/(\d{2})\/(\d{4})$/);
if (localMatch) {
const [, day, month, year] = localMatch;
return new Date(Number(year), Number(month) - 1, Number(day));
}
const dashedLocalMatch = value.match(/^(\d{2})-(\d{2})-(\d{4})$/);
if (dashedLocalMatch) {
const [, day, month, year] = dashedLocalMatch;
return new Date(Number(year), Number(month) - 1, Number(day));
}
const date = new Date(value);
return Number.isNaN(date.getTime()) ? null : date;
};
export const formatChartDateKey = (date: Date) => {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
};
const startOfWeek = (date: Date) => {
const nextDate = new Date(date);
const day = nextDate.getDay();
const offset = day === 0 ? -6 : 1 - day;
nextDate.setDate(nextDate.getDate() + offset);
nextDate.setHours(0, 0, 0, 0);
return nextDate;
};
export const getDateBucketKey = (value: string, bucket: DateBucket) => {
const date = parseChartDate(value);
if (!date) return value;
if (bucket === 'day') return formatChartDateKey(date);
if (bucket === 'week') return formatChartDateKey(startOfWeek(date));
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`;
};
export const formatDateBucketLabel = (value: string, bucket: DateBucket) => {
if (bucket === 'month') {
const match = value.match(/^(\d{4})-(\d{2})$/);
if (!match) return value;
const [, year, month] = match;
return new Intl.DateTimeFormat('pt-BR', { month: 'short', year: '2-digit' }).format(new Date(Number(year), Number(month) - 1, 1));
}
const date = parseChartDate(value);
if (!date) return value;
if (bucket === 'week') {
const endDate = new Date(date);
endDate.setDate(endDate.getDate() + 6);
const formatter = new Intl.DateTimeFormat('pt-BR', { day: '2-digit', month: '2-digit' });
return `${formatter.format(date)}-${formatter.format(endDate)}`;
}
return new Intl.DateTimeFormat('pt-BR', { day: '2-digit', month: '2-digit' }).format(date);
};
export const formatDateBucketLongLabel = (value: string, bucket: DateBucket) => {
if (bucket === 'month') {
const match = value.match(/^(\d{4})-(\d{2})$/);
if (!match) return value;
const [, year, month] = match;
return new Intl.DateTimeFormat('pt-BR', { month: 'long', year: 'numeric' }).format(new Date(Number(year), Number(month) - 1, 1));
}
const date = parseChartDate(value);
if (!date) return value;
if (bucket === 'week') {
const endDate = new Date(date);
endDate.setDate(endDate.getDate() + 6);
const formatter = new Intl.DateTimeFormat('pt-BR', { day: '2-digit', month: '2-digit', year: 'numeric' });
return `${formatter.format(date)} - ${formatter.format(endDate)}`;
}
return new Intl.DateTimeFormat('pt-BR', { day: '2-digit', month: '2-digit', year: 'numeric' }).format(date);
};
export const getMovingAverageWindow = (bucket: DateBucket) => {
if (bucket === 'day') return 7;
if (bucket === 'week') return 4;
return 3;
};
export const averageRecentValues = (values: number[], endIndex: number, windowSize: number) => {
const startIndex = Math.max(0, endIndex - windowSize + 1);
const windowValues = values.slice(startIndex, endIndex + 1);
if (!windowValues.length) return 0;
return windowValues.reduce((total, value) => total + value, 0) / windowValues.length;
};
export const normalizeUnknownLabel = (value: string, fallback: string) => {
const normalized = String(value || '').replace(/\s+/g, ' ').trim();
if (!normalized) return fallback;
if (/^(0|null|undefined|unknown|n\/a|-|sem nome)$/i.test(normalized)) return fallback;
return normalized;
};

View File

@@ -1,34 +0,0 @@
import { ArrowLeft } from 'lucide-react';
import { useLocation, useNavigate } from 'react-router-dom';
type BackButtonProps = {
fallbackTo: string;
label?: string;
};
const BackButton = ({ fallbackTo, label = 'Voltar' }: BackButtonProps) => {
const navigate = useNavigate();
const location = useLocation();
const handleClick = () => {
if (location.key !== 'default' && window.history.length > 1) {
navigate(-1);
return;
}
navigate(fallbackTo, { replace: true });
};
return (
<button
type="button"
onClick={handleClick}
className="inline-flex w-fit cursor-pointer items-center text-sm font-bold text-zinc-400 dark:text-dark-muted transition-colors hover:text-zinc-900 dark:hover:text-dark-text"
>
<ArrowLeft className="mr-2 h-4 w-4" />
{label}
</button>
);
};
export default BackButton;

View File

@@ -1,7 +1,6 @@
import React, { useRef, useState } from 'react';
import React, { useState, useRef } from 'react';
import { Calendar, RefreshCw, ChevronDown } from 'lucide-react';
import type { DateRange } from '../types';
import { endOfLocalDay, formatDateParam, parseLocalDateInput, rangeForDay, rangeForLastDays, rangeForPreviousDay, startOfLocalDay } from '../dateRanges';
interface DateRangePickerProps {
dateRange: DateRange;
@@ -12,15 +11,15 @@ interface DateRangePickerProps {
}
const PRESETS = [
{ label: 'Hoje', getRange: () => rangeForDay(new Date()) },
{ label: 'Ontem', getRange: () => rangeForPreviousDay() },
{ label: 'Últimos 7 dias', getRange: () => rangeForLastDays(7) },
{ label: 'Últimos 30 dias', getRange: () => rangeForLastDays(30) },
{ label: 'Este Mês', getRange: () => { const end = endOfLocalDay(new Date()); const start = startOfLocalDay(new Date(end.getFullYear(), end.getMonth(), 1)); return { start, end }; } },
{ label: 'Hoje', getRange: () => { const d = new Date(); d.setHours(0,0,0,0); return { start: d, end: new Date() }; } },
{ label: 'Ontem', getRange: () => { const d = new Date(); d.setDate(d.getDate() - 1); d.setHours(0,0,0,0); const end = new Date(d); end.setHours(23,59,59,999); return { start: d, end }; } },
{ label: 'Últimos 7 dias', getRange: () => { const end = new Date(); const start = new Date(); start.setDate(start.getDate() - 7); start.setHours(0,0,0,0); return { start, end }; } },
{ label: 'Últimos 30 dias', getRange: () => { const end = new Date(); const start = new Date(); start.setDate(start.getDate() - 30); start.setHours(0,0,0,0); return { start, end }; } },
{ label: 'Este Mês', getRange: () => { const end = new Date(); const start = new Date(end.getFullYear(), end.getMonth(), 1); return { start, end }; } },
{ label: 'Mês Passado', getRange: () => { const d = new Date(); const start = new Date(d.getFullYear(), d.getMonth() - 1, 1); const end = new Date(d.getFullYear(), d.getMonth(), 0, 23, 59, 59, 999); return { start, end }; } },
{ label: 'Últimos 90 dias', getRange: () => rangeForLastDays(90) },
{ label: 'Este Ano', getRange: () => { const end = endOfLocalDay(new Date()); const start = startOfLocalDay(new Date(end.getFullYear(), 0, 1)); return { start, end }; } },
{ label: 'Todo o Período', getRange: () => { const end = endOfLocalDay(new Date()); const start = startOfLocalDay(new Date(2000, 0, 1)); return { start, end }; } },
{ label: 'Últimos 90 dias', getRange: () => { const end = new Date(); const start = new Date(); start.setDate(start.getDate() - 90); start.setHours(0,0,0,0); return { start, end }; } },
{ label: 'Este Ano', getRange: () => { const end = new Date(); const start = new Date(end.getFullYear(), 0, 1); return { start, end }; } },
{ label: 'Todo o Período', getRange: () => { const end = new Date(); const start = new Date(2000, 0, 1); return { start, end }; } },
];
const REFRESH_OPTIONS = [
@@ -32,53 +31,43 @@ const REFRESH_OPTIONS = [
{ label: '5m', value: 300000 },
];
const getRangeKey = (range: DateRange) => `${formatDateParam(range.start)}:${formatDateParam(range.end)}`;
const DateRangePicker: React.FC<DateRangePickerProps> = ({ dateRange, onChange, refreshInterval, setRefreshInterval, onManualRefresh }) => {
const [isPresetOpen, setIsPresetOpen] = useState(false);
const [customDraft, setCustomDraft] = useState(() => ({
appliedKey: getRangeKey(dateRange),
range: dateRange
}));
const startRef = useRef<HTMLInputElement>(null);
const endRef = useRef<HTMLInputElement>(null);
const appliedRangeKey = getRangeKey(dateRange);
const draftRange = customDraft.appliedKey === appliedRangeKey ? customDraft.range : dateRange;
const formatDisplayDate = (date: Date) => {
return date.toLocaleDateString('pt-BR', { day: '2-digit', month: '2-digit', year: 'numeric' });
const formatShortDate = (date: Date) => {
return date.toLocaleDateString('pt-BR', { day: '2-digit', month: '2-digit', year: '2-digit' });
};
const formatDateForInput = (date: Date) => {
return formatDateParam(date);
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
};
const parseLocalDate = (value: string) => {
if (!value) return null;
const [year, month, day] = value.split('-');
return new Date(parseInt(year), parseInt(month) - 1, parseInt(day));
};
const handleStartChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newStart = parseLocalDateInput(e.target.value);
const newStart = parseLocalDate(e.target.value);
if (newStart && !isNaN(newStart.getTime())) {
const start = startOfLocalDay(newStart);
const end = draftRange.end < start ? endOfLocalDay(start) : draftRange.end;
setCustomDraft({ appliedKey: appliedRangeKey, range: { start, end } });
onChange({ ...dateRange, start: newStart });
}
};
const handleEndChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newEnd = parseLocalDateInput(e.target.value);
const newEnd = parseLocalDate(e.target.value);
if (newEnd && !isNaN(newEnd.getTime())) {
const end = endOfLocalDay(newEnd);
const start = draftRange.start > end ? startOfLocalDay(end) : draftRange.start;
setCustomDraft({ appliedKey: appliedRangeKey, range: { start, end } });
newEnd.setHours(23, 59, 59, 999);
onChange({ ...dateRange, end: newEnd });
}
};
const draftRangeKey = getRangeKey(draftRange);
const isDraftApplied = draftRangeKey === appliedRangeKey;
const handleApplyCustomRange = () => {
onChange(draftRange);
setIsPresetOpen(false);
};
const openPicker = (ref: React.RefObject<HTMLInputElement | null>) => {
if (ref.current) {
try {
@@ -87,29 +76,29 @@ const DateRangePicker: React.FC<DateRangePickerProps> = ({ dateRange, onChange,
} else {
ref.current.focus();
}
} catch {
} catch (e) {
ref.current.focus();
}
}
};
return (
<div className="flex w-full flex-col gap-2 sm:w-auto sm:flex-row sm:flex-wrap sm:items-center sm:gap-3">
<div className="flex flex-wrap items-center gap-3">
{/* Date Presets Dropdown */}
<div className="relative w-full sm:w-auto">
<div className="relative">
<button
onClick={() => setIsPresetOpen(!isPresetOpen)}
className="flex min-h-11 w-full items-center gap-2 rounded-xl border border-dark-border bg-dark-card px-4 py-2.5 text-sm font-medium text-dark-text shadow-sm transition-colors hover:border-brand-primary cursor-pointer sm:w-auto"
className="flex items-center gap-2 bg-dark-card border border-dark-border px-4 py-2.5 rounded-xl shadow-sm hover:border-brand-primary transition-colors text-sm font-medium text-dark-text cursor-pointer"
>
<Calendar size={16} className="text-dark-muted shrink-0" />
<span>{formatDisplayDate(dateRange.start)} - {formatDisplayDate(dateRange.end)}</span>
<span>{formatShortDate(dateRange.start)} - {formatShortDate(dateRange.end)}</span>
<ChevronDown size={14} className="text-dark-muted ml-1" />
</button>
{isPresetOpen && (
<>
<div className="fixed inset-0 z-10" onClick={() => setIsPresetOpen(false)}></div>
<div className="fixed inset-x-3 top-20 z-20 mt-0 max-h-[calc(100dvh-6rem)] overflow-y-auto rounded-xl border border-dark-border bg-dark-card py-2 shadow-xl sm:absolute sm:right-0 sm:top-auto sm:inset-x-auto sm:mt-2 sm:max-h-[32rem] sm:w-56">
<div className="absolute right-0 mt-2 w-56 bg-dark-card border border-dark-border rounded-xl shadow-xl z-20 py-2 max-h-[32rem] overflow-y-auto">
<div className="px-4 pb-3 pt-1 border-b border-dark-border mb-2 flex flex-col gap-2">
<span className="text-xs font-bold text-dark-muted uppercase tracking-widest">Período Customizado</span>
<div className="flex flex-col gap-1.5">
@@ -119,11 +108,11 @@ const DateRangePicker: React.FC<DateRangePickerProps> = ({ dateRange, onChange,
className="relative bg-dark-input border border-dark-border text-dark-text text-xs rounded-lg px-2 py-1 focus-within:border-brand-primary w-full cursor-pointer overflow-hidden flex items-center h-6"
onClick={() => openPicker(startRef)}
>
<span className="w-full text-center">{formatDisplayDate(draftRange.start)}</span>
<span className="w-full text-center">{formatShortDate(dateRange.start)}</span>
<input
ref={startRef}
type="date"
value={formatDateForInput(draftRange.start)}
value={formatDateForInput(dateRange.start)}
onChange={handleStartChange}
className="absolute inset-0 opacity-0 cursor-pointer"
/>
@@ -135,24 +124,16 @@ const DateRangePicker: React.FC<DateRangePickerProps> = ({ dateRange, onChange,
className="relative bg-dark-input border border-dark-border text-dark-text text-xs rounded-lg px-2 py-1 focus-within:border-brand-primary w-full cursor-pointer overflow-hidden flex items-center h-6"
onClick={() => openPicker(endRef)}
>
<span className="w-full text-center">{formatDisplayDate(draftRange.end)}</span>
<span className="w-full text-center">{formatShortDate(dateRange.end)}</span>
<input
ref={endRef}
type="date"
value={formatDateForInput(draftRange.end)}
value={formatDateForInput(dateRange.end)}
onChange={handleEndChange}
className="absolute inset-0 opacity-0 cursor-pointer"
/>
</div>
</div>
<button
type="button"
onClick={handleApplyCustomRange}
disabled={isDraftApplied}
className="mt-1 inline-flex h-8 w-full items-center justify-center rounded-lg bg-brand-primary px-3 text-xs font-bold text-white transition-colors hover:bg-brand-primary/90 disabled:cursor-not-allowed disabled:bg-dark-input disabled:text-dark-muted cursor-pointer"
>
Aplicar
</button>
</div>
</div>
<span className="px-4 text-xs font-bold text-dark-muted uppercase tracking-widest mb-1 block">Atalhos</span>
@@ -160,9 +141,7 @@ const DateRangePicker: React.FC<DateRangePickerProps> = ({ dateRange, onChange,
<button
key={preset.label}
onClick={() => {
const nextRange = preset.getRange();
setCustomDraft({ appliedKey: getRangeKey(nextRange), range: nextRange });
onChange(nextRange);
onChange(preset.getRange());
setIsPresetOpen(false);
}}
className="w-full text-left px-4 py-2 text-sm text-dark-muted hover:text-dark-text hover:bg-dark-input transition-colors cursor-pointer"
@@ -177,7 +156,7 @@ const DateRangePicker: React.FC<DateRangePickerProps> = ({ dateRange, onChange,
{/* Auto Refresh Dropdown */}
{setRefreshInterval && onManualRefresh && (
<div className="flex min-h-11 w-full items-center overflow-hidden rounded-xl border border-dark-border bg-dark-card shadow-sm transition-colors hover:border-brand-primary sm:w-auto">
<div className="flex items-center bg-dark-card border border-dark-border rounded-xl shadow-sm overflow-hidden hover:border-brand-primary transition-colors">
<button
onClick={onManualRefresh}
className="flex items-center gap-1.5 px-4 py-2.5 text-sm font-medium text-dark-text hover:text-brand-primary hover:bg-dark-input transition-colors border-r border-dark-border cursor-pointer"

View File

@@ -1,22 +1,14 @@
import { useState, useEffect } from 'react';
import { Outlet, Link, useLocation } from 'react-router-dom';
import { LayoutDashboard, Users, BarChart3, ChevronLeft, ChevronRight, Package, LogOut, Megaphone, Grid3X3, Shield, Moon, Sun, Tags, Boxes, ClipboardList, Menu, X, BriefcaseBusiness, Radio } from 'lucide-react';
import { LayoutDashboard, Users, BarChart3, ChevronLeft, ChevronRight, Package, Loader2, LogOut } from 'lucide-react';
import type { DateRange, OrderData } from '../types';
import { logout } from '../dataService';
import { rangeForLastDays } from '../dateRanges';
const emptyOrdersData: OrderData[] = [];
type ThemeMode = 'dark' | 'offwhite';
import { fetchData, logout } from '../dataService';
const Layout = () => {
const location = useLocation();
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(() => {
return localStorage.getItem('graph_sidebar_collapsed') === 'true';
});
const [isMobileNavOpen, setIsMobileNavOpen] = useState(false);
const [themeMode, setThemeMode] = useState<ThemeMode>(() => {
return localStorage.getItem('nexstar_theme') === 'offwhite' ? 'offwhite' : 'dark';
});
const [dateRange, setDateRange] = useState<DateRange>(() => {
const saved = localStorage.getItem('nexstar_date_range');
@@ -26,19 +18,39 @@ const Layout = () => {
return { start: new Date(parsed.start), end: new Date(parsed.end) };
} catch (e) { console.error(e); }
}
return rangeForLastDays(30);
const end = new Date();
const start = new Date();
start.setMonth(start.getMonth() - 1);
return { start, end };
});
const [ordersData, setOrdersData] = useState<OrderData[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [refreshInterval, setRefreshInterval] = useState<number>(() => {
const saved = localStorage.getItem('nexstar_refresh_interval');
return saved ? Number(saved) : 0;
});
const loadData = async (showLoading = false) => {
if (showLoading) setIsLoading(true);
const data = await fetchData();
setOrdersData(data);
if (showLoading) setIsLoading(false);
};
useEffect(() => {
document.documentElement.classList.add('dark');
document.documentElement.dataset.theme = themeMode;
localStorage.setItem('nexstar_theme', themeMode);
}, [themeMode]);
loadData(true);
}, []);
useEffect(() => {
if (refreshInterval === 0) return;
const intervalId = setInterval(() => {
loadData(false);
}, refreshInterval);
return () => clearInterval(intervalId);
}, [refreshInterval]);
useEffect(() => {
localStorage.setItem('nexstar_refresh_interval', refreshInterval.toString());
@@ -56,158 +68,86 @@ const Layout = () => {
setIsSidebarCollapsed(newState);
localStorage.setItem('graph_sidebar_collapsed', String(newState));
};
const toggleTheme = () => {
setThemeMode(current => current === 'dark' ? 'offwhite' : 'dark');
};
const showSidebarLabels = !isSidebarCollapsed || isMobileNavOpen;
const adminNavigation = [
{ name: 'Olist', href: '/admin/olist', icon: Radio },
{ name: 'Usuários', href: '/admin/users', icon: Shield }
];
const navigationSections = [
{
label: 'Painel',
items: [
{ name: 'Dashboard', href: '/graph', icon: LayoutDashboard },
],
},
{
label: 'Operação',
items: [
{ name: 'Produtos', href: '/products', icon: Package },
{ name: 'Dados Pendentes', href: '/planning-issues', icon: ClipboardList },
{ name: 'Cadastros', href: '/registrations', icon: Tags },
{ name: 'Suprimentos', href: '/supplies', icon: Boxes },
],
},
{
label: 'Comercial',
items: [
{ name: 'Clientes', href: '/clients', icon: Users },
{ name: 'Vendedores', href: '/sellers', icon: BriefcaseBusiness },
{ name: 'RFV', href: '/rfm', icon: Grid3X3 },
{ name: 'Campanhas', href: '/campaigns', icon: Megaphone },
],
},
{ label: 'Administração', items: adminNavigation },
const navigation = [
{ name: 'Dashboard', href: '/graph', icon: LayoutDashboard },
{ name: 'Produtos', href: '/products', icon: Package },
{ name: 'Clientes', href: '/clients', icon: Users },
];
return (
<div className="app-shell flex h-dvh min-w-0 text-dark-text overflow-hidden">
{isMobileNavOpen && (
<button
type="button"
className="fixed inset-0 z-40 bg-black/55 md:hidden"
onClick={() => setIsMobileNavOpen(false)}
aria-label="Fechar menu"
/>
)}
<div className="flex h-screen bg-dark-bg text-dark-text overflow-hidden">
{/* Sidebar */}
<aside className={`fixed inset-y-0 left-0 z-50 flex w-72 flex-col border-r border-dark-border bg-dark-sidebar transition-transform duration-300 md:relative md:z-auto md:translate-x-0 md:transition-[width] ${isSidebarCollapsed ? 'md:w-20' : 'md:w-64'} ${isMobileNavOpen ? 'translate-x-0' : '-translate-x-full'}`}>
<div className={`flex h-16 items-center border-b border-dark-border px-5 md:h-20 md:px-6 ${isSidebarCollapsed ? 'md:justify-center' : 'justify-between'}`}>
<aside className={`bg-dark-sidebar border-r border-dark-border flex flex-col transition-all duration-300 ${isSidebarCollapsed ? 'w-20' : 'w-64'}`}>
<div className={`h-20 px-6 border-b border-dark-border flex items-center ${isSidebarCollapsed ? 'justify-center' : 'justify-between'}`}>
<div className="flex items-center gap-2">
<div className="rounded-lg border border-brand-primary/25 bg-brand-primary/15 p-1.5 text-brand-primary shadow-sm shadow-brand-primary/10">
<div className={`p-1.5 bg-brand-primary/20 rounded-lg text-brand-primary`}>
<BarChart3 className="w-6 h-6" />
</div>
{showSidebarLabels && <span className="text-xl font-bold text-dark-text">Nexstar</span>}
{!isSidebarCollapsed && <span className="text-xl font-bold text-dark-text">Nexstar</span>}
</div>
<button onClick={() => setIsMobileNavOpen(false)} className="text-dark-muted transition-colors hover:text-dark-text md:hidden" aria-label="Fechar menu">
<X className="h-5 w-5" />
</button>
{!isSidebarCollapsed && (
<button onClick={toggleSidebar} className="hidden cursor-pointer text-dark-muted transition-colors hover:text-dark-text md:block">
<button onClick={toggleSidebar} className="text-dark-muted hover:text-dark-text transition-colors cursor-pointer">
<ChevronLeft className="w-5 h-5" />
</button>
)}
</div>
{isSidebarCollapsed && (
<div className="hidden border-b border-dark-border p-4 md:flex md:justify-center">
<div className="p-4 border-b border-dark-border flex justify-center">
<button onClick={toggleSidebar} className="text-dark-muted hover:text-dark-text transition-colors cursor-pointer">
<ChevronRight className="w-5 h-5" />
</button>
</div>
)}
<nav className="flex-1 space-y-5 overflow-y-auto px-3 py-4">
{navigationSections.map((section) => (
<div
key={section.label}
className={`space-y-1.5 ${
isSidebarCollapsed ? 'md:border-t md:border-dark-border md:pt-5 md:first:border-t-0 md:first:pt-0' : ''
}`}
>
{showSidebarLabels && (
<div className="px-3 pb-1 text-[11px] font-bold uppercase tracking-widest text-dark-muted">
{section.label}
</div>
)}
{section.items.map((item) => {
const isActive = location.pathname === item.href || (item.href !== '/graph' && location.pathname.startsWith(item.href));
return (
<Link
key={item.name}
to={item.href}
onClick={() => setIsMobileNavOpen(false)}
className={`group flex min-h-11 items-center rounded-lg px-3 py-2.5 text-sm transition-all ${
isSidebarCollapsed ? 'gap-3 md:justify-center' : 'gap-3'
} ${
isActive
? 'bg-brand-primary/12 text-brand-primary font-semibold shadow-sm shadow-brand-primary/10 ring-1 ring-brand-primary/15'
: 'text-dark-muted hover:bg-dark-input/70 hover:text-dark-text'
}`}
title={isSidebarCollapsed ? item.name : undefined}
>
<item.icon className="h-4.5 w-4.5 shrink-0" />
{showSidebarLabels && <span className="truncate font-medium">{item.name}</span>}
</Link>
);
})}
</div>
))}
<nav className="flex-1 p-4 space-y-2 overflow-y-auto">
{navigation.map((item) => {
const isActive = location.pathname === item.href || (item.href !== '/graph' && location.pathname.startsWith(item.href));
return (
<Link
key={item.name}
to={item.href}
className={`flex items-center space-x-3 px-4 py-3 rounded-xl transition-all ${
isActive
? 'bg-brand-primary/10 text-brand-primary font-semibold shadow-md shadow-brand-primary/5'
: 'text-dark-muted hover:bg-dark-card hover:text-dark-text'
}`}
>
<item.icon className="w-5 h-5 shrink-0" />
{!isSidebarCollapsed && <span className="font-medium">{item.name}</span>}
</Link>
);
})}
</nav>
<div className="p-4 border-t border-dark-border">
<button
onClick={logout}
className={`flex w-full items-center rounded-xl px-4 py-3 text-red-500 transition-all hover:bg-red-500/10 cursor-pointer ${isSidebarCollapsed ? 'gap-3 md:justify-center' : 'gap-3'}`}
className={`w-full flex items-center text-red-500 hover:bg-red-500/10 px-4 py-3 rounded-xl transition-all cursor-pointer ${isSidebarCollapsed ? 'justify-center' : 'space-x-3'}`}
>
<LogOut className="w-5 h-5 shrink-0" />
{showSidebarLabels && <span className="font-medium">Sair</span>}
{!isSidebarCollapsed && <span className="font-medium">Sair</span>}
</button>
</div>
</aside>
{/* Main Content */}
<main className="flex min-w-0 flex-1 flex-col overflow-hidden">
<main className="flex-1 flex flex-col h-screen overflow-hidden">
{/* Header */}
<header className="flex h-16 shrink-0 items-center justify-between border-b border-dark-border bg-dark-header px-4 md:h-20 md:px-8">
<div className="flex min-w-0 items-center gap-3">
<button
type="button"
onClick={() => setIsMobileNavOpen(true)}
className="inline-flex h-11 w-11 shrink-0 items-center justify-center rounded-xl border border-dark-border bg-dark-input text-dark-text md:hidden"
aria-label="Abrir menu"
>
<Menu className="h-5 w-5" />
</button>
<h2 className="truncate text-lg font-bold text-dark-text md:text-xl">Painel de Análise</h2>
</div>
<button
type="button"
onClick={toggleTheme}
className="inline-flex h-11 w-11 shrink-0 items-center justify-center rounded-xl border border-transparent bg-transparent text-dark-text transition-colors hover:border-dark-border hover:bg-dark-input hover:text-brand-primary cursor-pointer"
title={themeMode === 'dark' ? 'Usar modo off-white' : 'Usar modo escuro'}
aria-label={themeMode === 'dark' ? 'Usar modo off-white' : 'Usar modo escuro'}
>
{themeMode === 'dark' ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
</button>
<header className="h-20 bg-dark-header border-b border-dark-border flex items-center px-8 shrink-0">
<h2 className="text-xl font-bold text-dark-text">Painel de Análise</h2>
</header>
{/* Content Area */}
<div className="app-content relative flex-1 overflow-y-auto px-4 py-5 pb-24 sm:px-5 md:p-8">
<Outlet context={{ dateRange, setDateRange, ordersData: emptyOrdersData, isDataLoading: false, refreshInterval, setRefreshInterval, themeMode }} />
<div className="flex-1 overflow-y-auto p-8 relative">
{isLoading ? (
<div className="flex items-center justify-center h-full">
<Loader2 className="w-8 h-8 text-brand-primary animate-spin" />
</div>
) : (
<Outlet context={{ dateRange, setDateRange, ordersData, refreshInterval, setRefreshInterval, loadData }} />
)}
</div>
</main>
</div>

View File

@@ -1,140 +0,0 @@
import { ChevronsLeft, ChevronLeft, ChevronRight, ChevronsRight } from 'lucide-react';
type PaginationControlsProps = {
totalItems: number;
currentPage: number;
totalPages: number;
pageSize: number;
pageSizeOptions: number[];
itemLabel: string;
pageSizeLabel: string;
startIndex: number;
endIndex: number;
onPageChange: (page: number) => void;
onPageSizeChange: (pageSize: number) => void;
className?: string;
stacked?: boolean;
};
const clampPage = (page: number, totalPages: number) => {
if (!Number.isFinite(page)) return 1;
return Math.min(Math.max(1, Math.trunc(page)), Math.max(1, totalPages));
};
const PaginationControls = ({
totalItems,
currentPage,
totalPages,
pageSize,
pageSizeOptions,
itemLabel,
pageSizeLabel,
startIndex,
endIndex,
onPageChange,
onPageSizeChange,
className = 'px-6 py-4 border-t border-zinc-100 dark:border-dark-border',
stacked = false
}: PaginationControlsProps) => {
const safeTotalPages = Math.max(1, totalPages);
const safeCurrentPage = clampPage(currentPage, safeTotalPages);
const isFirstPage = safeCurrentPage <= 1;
const isLastPage = safeCurrentPage >= safeTotalPages || totalItems === 0;
const goToPage = (page: number) => {
onPageChange(clampPage(page, safeTotalPages));
};
return (
<div className={`${className} ${stacked ? 'flex flex-col items-center gap-3' : 'flex flex-col items-center justify-between gap-4 lg:flex-row'}`}>
<div className="flex flex-wrap items-center justify-center gap-2 text-sm text-zinc-500 dark:text-dark-muted">
<span>Mostrar</span>
<select
value={pageSize}
onChange={(event) => onPageSizeChange(Number(event.target.value))}
className="bg-dark-card border border-dark-border rounded-lg px-2 py-1 focus:outline-none focus:border-brand-primary cursor-pointer text-dark-text"
>
{pageSizeOptions.map(option => (
<option key={option} value={option}>{option}</option>
))}
</select>
<span>{pageSizeLabel}</span>
</div>
<div className={stacked ? 'flex flex-col items-center gap-2 text-sm' : 'flex flex-wrap items-center justify-center gap-3 text-sm'}>
<span className="text-center text-zinc-500 dark:text-dark-muted">
Mostrando {totalItems > 0 ? startIndex + 1 : 0} a {endIndex} de {totalItems} {itemLabel}
</span>
<div className="flex items-center gap-2">
<div className="flex gap-1">
<button
type="button"
onClick={() => goToPage(1)}
disabled={isFirstPage || totalItems === 0}
className="p-1 rounded-lg border border-dark-border disabled:opacity-50 disabled:cursor-not-allowed hover:border-brand-primary transition-colors text-dark-muted hover:text-dark-text cursor-pointer bg-dark-card"
aria-label="Primeira página"
title="Primeira página"
>
<ChevronsLeft className="w-5 h-5" />
</button>
<button
type="button"
onClick={() => goToPage(safeCurrentPage - 1)}
disabled={isFirstPage || totalItems === 0}
className="p-1 rounded-lg border border-dark-border disabled:opacity-50 disabled:cursor-not-allowed hover:border-brand-primary transition-colors text-dark-muted hover:text-dark-text cursor-pointer bg-dark-card"
aria-label="Página anterior"
title="Página anterior"
>
<ChevronLeft className="w-5 h-5" />
</button>
</div>
<label className="flex items-center gap-1 text-xs font-semibold text-zinc-500 dark:text-dark-muted">
<span>Página</span>
<input
type="text"
inputMode="numeric"
pattern="[0-9]*"
value={safeCurrentPage}
onFocus={(event) => event.currentTarget.select()}
onChange={(event) => {
const nextPage = event.target.value.replace(/\D/g, '');
if (!nextPage) return;
goToPage(Number(nextPage));
}}
className="h-8 w-16 rounded-lg border border-dark-border bg-dark-card px-2 text-center text-sm font-bold text-dark-text focus:outline-none focus:border-brand-primary"
aria-label="Ir para página"
/>
<span>de {safeTotalPages}</span>
</label>
<div className="flex gap-1">
<button
type="button"
onClick={() => goToPage(safeCurrentPage + 1)}
disabled={isLastPage}
className="p-1 rounded-lg border border-dark-border disabled:opacity-50 disabled:cursor-not-allowed hover:border-brand-primary transition-colors text-dark-muted hover:text-dark-text cursor-pointer bg-dark-card"
aria-label="Próxima página"
title="Próxima página"
>
<ChevronRight className="w-5 h-5" />
</button>
<button
type="button"
onClick={() => goToPage(safeTotalPages)}
disabled={isLastPage}
className="p-1 rounded-lg border border-dark-border disabled:opacity-50 disabled:cursor-not-allowed hover:border-brand-primary transition-colors text-dark-muted hover:text-dark-text cursor-pointer bg-dark-card"
aria-label="Última página"
title="Última página"
>
<ChevronsRight className="w-5 h-5" />
</button>
</div>
</div>
</div>
</div>
);
};
export default PaginationControls;

View File

@@ -1,40 +0,0 @@
import { formatColorLabel } from '../displayFormatters';
import { getProductColor } from '../productColors';
export const ProductColorSwatch = ({
label,
className = 'h-2.5 w-2.5'
}: {
label: string;
className?: string;
}) => (
<span
className={`${className} shrink-0 rounded-full`}
style={{
backgroundColor: getProductColor(label),
boxShadow: '0 0 0 1px var(--color-dark-card), 0 0 0 2px var(--color-dark-border)'
}}
/>
);
const ProductColorBadge = ({
label,
emptyLabel = 'Sem cor',
className = ''
}: {
label?: string;
emptyLabel?: string;
className?: string;
}) => {
const normalizedLabel = label?.trim();
const displayLabel = normalizedLabel ? formatColorLabel(normalizedLabel) : emptyLabel;
return (
<span className={`inline-flex max-w-full items-center gap-2 rounded-full border border-dark-border bg-dark-input px-2.5 py-1 text-xs font-bold text-dark-text ${className}`}>
<ProductColorSwatch label={displayLabel} className="h-2 w-2" />
<span className="truncate">{displayLabel}</span>
</span>
);
};
export default ProductColorBadge;

View File

@@ -1,21 +0,0 @@
import { getProductTypeConfig, type ProductTypeKey } from '../productClassification';
type ProductTypeBadgeProps = {
type: ProductTypeKey;
className?: string;
};
const ProductTypeBadge = ({ type, className = '' }: ProductTypeBadgeProps) => {
const config = getProductTypeConfig(type);
return (
<span
className={`inline-flex w-fit items-center whitespace-nowrap rounded-full border px-2 py-0.5 text-[10px] font-bold ${config.badgeClassName} ${className}`}
title={config.description}
>
{config.label}
</span>
);
};
export default ProductTypeBadge;

View File

@@ -1,18 +0,0 @@
type RefreshStatusProps = {
isRefreshing: boolean;
label?: string;
className?: string;
};
const RefreshStatus = ({ isRefreshing, label = 'Atualizando dados', className = '' }: RefreshStatusProps) => {
if (!isRefreshing) return null;
return (
<div className={`h-1 overflow-hidden rounded-full border border-dark-border bg-dark-input/70 ${className}`} role="status" aria-live="polite">
<span className="sr-only">{label}</span>
<span className="refresh-progress block h-full w-1/3 rounded-full bg-brand-primary" />
</div>
);
};
export default RefreshStatus;

View File

@@ -1,201 +0,0 @@
import { useState } from 'react';
import { RotateCcw, Save, X } from 'lucide-react';
import { CUT_FAMILY_RULES, type CutFamilyKey } from '../analytics/cutting';
import { editableProductTypeOptions, getProductTypeConfig, resolveProductType, type ProductTypeKey } from '../productClassification';
import type { CutProductOverride } from '../types';
import ProductTypeBadge from './ProductTypeBadge';
type SkuPlanningModalProduct = {
id: string;
name: string;
color?: string;
size?: string;
};
type SkuPlanningModalProps = {
product: SkuPlanningModalProduct;
override?: CutProductOverride;
isSaving?: boolean;
onClose: () => void;
onSave: (override: CutProductOverride | null) => void | Promise<void>;
};
const familyOptions: Array<{ value: CutFamilyKey | ''; label: string }> = [
{ value: '', label: 'Auto' },
...CUT_FAMILY_RULES.map(rule => ({ value: rule.key, label: `${rule.materialLabel} · ${rule.label}` })),
{ value: 'OUTROS', label: 'Sem regra' }
];
const buildOverride = ({
familyKey,
color,
size,
productType,
planningNotes
}: Required<Pick<CutProductOverride, 'color' | 'size' | 'planningNotes'>> & {
familyKey: CutFamilyKey | '';
productType: ProductTypeKey | '';
}): CutProductOverride | null => {
const next: CutProductOverride = {
familyKey,
color: color.trim(),
size: size.trim().toUpperCase(),
productType,
planningNotes: planningNotes.trim()
};
if (!next.familyKey && !next.color && !next.size && !next.productType && !next.planningNotes) {
return null;
}
return next;
};
const SkuPlanningModal = ({ product, override, isSaving = false, onClose, onSave }: SkuPlanningModalProps) => {
const [familyKey, setFamilyKey] = useState<CutFamilyKey | ''>(override?.familyKey || '');
const [color, setColor] = useState(override?.color || '');
const [size, setSize] = useState(override?.size || '');
const [productType, setProductType] = useState<ProductTypeKey | ''>(override?.productType || '');
const [planningNotes, setPlanningNotes] = useState(override?.planningNotes || '');
const resolvedType = resolveProductType(product.name, { productType });
const resolvedConfig = getProductTypeConfig(resolvedType);
const handleSave = () => {
void onSave(buildOverride({ familyKey, color, size, productType, planningNotes }));
};
const handleClear = () => {
void onSave(null);
};
return (
<div
className="fixed inset-0 z-50 flex h-dvh items-center justify-center overflow-y-auto bg-zinc-950/80 p-4 backdrop-blur-md dark:bg-black/75"
role="dialog"
aria-modal="true"
aria-label={`Editar planejamento do SKU ${product.id}`}
onClick={onClose}
>
<div
className="w-full max-w-2xl rounded-2xl border border-dark-border bg-dark-card shadow-2xl"
onClick={(event) => event.stopPropagation()}
>
<div className="flex items-start justify-between gap-4 border-b border-dark-border p-5">
<div className="min-w-0">
<div className="font-mono text-[10px] font-bold uppercase tracking-widest text-dark-muted">SKU #{product.id}</div>
<h2 className="mt-1 truncate text-lg font-bold text-dark-text" title={product.name}>{product.name}</h2>
<div className="mt-2 flex flex-wrap items-center gap-2">
<ProductTypeBadge type={resolvedType} />
<span className="text-xs font-semibold text-dark-muted">{resolvedConfig.description}</span>
</div>
</div>
<button
type="button"
onClick={onClose}
className="inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-xl border border-dark-border bg-dark-input text-dark-muted transition-colors hover:border-brand-primary hover:text-dark-text cursor-pointer"
title="Fechar"
aria-label="Fechar"
>
<X className="h-4 w-4" />
</button>
</div>
<div className="grid grid-cols-1 gap-4 p-5 md:grid-cols-2">
<label className="space-y-2">
<span className="text-xs font-bold uppercase tracking-widest text-dark-muted">Tipo de produto</span>
<select
value={productType}
onChange={(event) => setProductType(event.target.value as ProductTypeKey | '')}
className="h-10 w-full rounded-lg border border-dark-border bg-dark-input px-3 text-sm font-bold text-dark-text outline-none transition-colors focus:border-brand-primary cursor-pointer"
>
<option value="">Auto ({getProductTypeConfig(resolveProductType(product.name)).label})</option>
{editableProductTypeOptions.map(option => (
<option key={option.value} value={option.value}>{option.label}</option>
))}
</select>
</label>
<label className="space-y-2">
<span className="text-xs font-bold uppercase tracking-widest text-dark-muted">Família de corte</span>
<select
value={familyKey}
onChange={(event) => setFamilyKey(event.target.value as CutFamilyKey | '')}
className="h-10 w-full rounded-lg border border-dark-border bg-dark-input px-3 text-sm font-bold text-dark-text outline-none transition-colors focus:border-brand-primary cursor-pointer"
>
{familyOptions.map(option => (
<option key={option.value || 'auto'} value={option.value}>{option.label}</option>
))}
</select>
</label>
<label className="space-y-2">
<span className="text-xs font-bold uppercase tracking-widest text-dark-muted">Cor</span>
<input
type="text"
value={color}
onChange={(event) => setColor(event.target.value)}
placeholder={product.color || 'Auto pelo nome'}
className="h-10 w-full rounded-lg border border-dark-border bg-dark-input px-3 text-sm font-bold text-dark-text outline-none transition-colors placeholder:text-dark-muted focus:border-brand-primary"
/>
</label>
<label className="space-y-2">
<span className="text-xs font-bold uppercase tracking-widest text-dark-muted">Tamanho</span>
<input
type="text"
value={size}
onChange={(event) => setSize(event.target.value)}
placeholder={product.size || 'Auto pelo nome'}
className="h-10 w-full rounded-lg border border-dark-border bg-dark-input px-3 text-sm font-bold uppercase text-dark-text outline-none transition-colors placeholder:normal-case placeholder:text-dark-muted focus:border-brand-primary"
/>
</label>
<label className="space-y-2 md:col-span-2">
<span className="text-xs font-bold uppercase tracking-widest text-dark-muted">Notas de planejamento</span>
<textarea
value={planningNotes}
onChange={(event) => setPlanningNotes(event.target.value)}
rows={3}
placeholder="Ex.: revisar consumo, material usado, regra temporária..."
className="w-full resize-none rounded-lg border border-dark-border bg-dark-input px-3 py-2 text-sm font-semibold text-dark-text outline-none transition-colors placeholder:text-dark-muted focus:border-brand-primary"
/>
</label>
</div>
<div className="flex flex-col gap-2 border-t border-dark-border p-5 sm:flex-row sm:items-center sm:justify-between">
<button
type="button"
onClick={handleClear}
disabled={isSaving}
className="inline-flex items-center justify-center gap-2 rounded-xl border border-dark-border bg-dark-input px-4 py-2.5 text-sm font-bold text-dark-muted transition-colors hover:border-red-400/40 hover:text-red-300 disabled:cursor-not-allowed disabled:opacity-50 cursor-pointer"
>
<RotateCcw className="h-4 w-4" />
Limpar override
</button>
<div className="flex gap-2 sm:justify-end">
<button
type="button"
onClick={onClose}
disabled={isSaving}
className="inline-flex flex-1 items-center justify-center rounded-xl border border-dark-border bg-dark-input px-4 py-2.5 text-sm font-bold text-dark-text transition-colors hover:border-brand-primary disabled:cursor-not-allowed disabled:opacity-50 sm:flex-none cursor-pointer"
>
Cancelar
</button>
<button
type="button"
onClick={handleSave}
disabled={isSaving}
className="inline-flex flex-1 items-center justify-center gap-2 rounded-xl border border-brand-primary/30 bg-brand-primary/15 px-4 py-2.5 text-sm font-bold text-brand-primary transition-colors hover:border-brand-primary disabled:cursor-not-allowed disabled:opacity-50 sm:flex-none cursor-pointer"
>
<Save className="h-4 w-4" />
{isSaving ? 'Salvando' : 'Salvar'}
</button>
</div>
</div>
</div>
</div>
);
};
export default SkuPlanningModal;

View File

@@ -1,125 +0,0 @@
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;

146
src/data.json Normal file
View File

@@ -0,0 +1,146 @@
[
{
"Nome_Cliente": "Luiz Felipe Oliveira Silva",
"Data_Pedido": "30-04-2026",
"Valor_Pedido": 19.9,
"ID_Produto": "951438842",
"Descricao_Produto": "IMPRESSÃO DTF PERSONALIZADO 57X100 (1 METRO)",
"Quantidade": 1,
"Valor_Unitario": 19.9
},
{
"Nome_Cliente": "61.855.899 WALTER PONCE JUNIOR",
"Data_Pedido": "30-04-2026",
"Valor_Pedido": 69.65,
"ID_Produto": "951438842",
"Descricao_Produto": "IMPRESSÃO DTF PERSONALIZADO 57X100 (1 METRO)",
"Quantidade": 3,
"Valor_Unitario": 19.9
},
{
"Nome_Cliente": "61.855.899 WALTER PONCE JUNIOR",
"Data_Pedido": "30-04-2026",
"Valor_Pedido": 69.65,
"ID_Produto": "978770094",
"Descricao_Produto": "IMPRESSÃO DTF PERSONALIZADO 57X0,50 (50 CENTÍMETROS)",
"Quantidade": 1,
"Valor_Unitario": 9.95
},
{
"Nome_Cliente": "Guilherme de Souza",
"Data_Pedido": "30-04-2026",
"Valor_Pedido": 65.66,
"ID_Produto": "951438842",
"Descricao_Produto": "IMPRESSÃO DTF PERSONALIZADO 57X100 (1 METRO)",
"Quantidade": 3,
"Valor_Unitario": 20.51889
},
{
"Nome_Cliente": "Guilherme de Souza",
"Data_Pedido": "30-04-2026",
"Valor_Pedido": 65.66,
"ID_Produto": "978776637",
"Descricao_Produto": "IMPRESSÃO DTF PERSONALIZADO 57X0,20 (20 CENTÍMETROS)",
"Quantidade": 1,
"Valor_Unitario": 4.103778
},
{
"Nome_Cliente": "Guilherme de Souza",
"Data_Pedido": "30-04-2026",
"Valor_Pedido": 24.54,
"ID_Produto": "919483299",
"Descricao_Produto": "BASE LISA CAMISETA COR PRETO TAMANHO - P",
"Quantidade": 1,
"Valor_Unitario": 12.27009
},
{
"Nome_Cliente": "Guilherme de Souza",
"Data_Pedido": "30-04-2026",
"Valor_Pedido": 24.54,
"ID_Produto": "919483307",
"Descricao_Produto": "BASE LISA CAMISETA COR PRETO TAMANHO - G",
"Quantidade": 1,
"Valor_Unitario": 12.27009
},
{
"Nome_Cliente": "61.855.899 WALTER PONCE JUNIOR",
"Data_Pedido": "30-04-2026",
"Valor_Pedido": 95.2,
"ID_Produto": "919483303",
"Descricao_Produto": "BASE LISA CAMISETA COR PRETO TAMANHO - M",
"Quantidade": 2,
"Valor_Unitario": 11.9
},
{
"Nome_Cliente": "61.855.899 WALTER PONCE JUNIOR",
"Data_Pedido": "30-04-2026",
"Valor_Pedido": 95.2,
"ID_Produto": "919483311",
"Descricao_Produto": "BASE LISA CAMISETA COR PRETO TAMANHO - GG",
"Quantidade": 2,
"Valor_Unitario": 11.9
},
{
"Nome_Cliente": "61.855.899 WALTER PONCE JUNIOR",
"Data_Pedido": "30-04-2026",
"Valor_Pedido": 95.2,
"ID_Produto": "976044109",
"Descricao_Produto": "BASE LISA CAMISETA COR MARINHO TAMANHO - GG",
"Quantidade": 2,
"Valor_Unitario": 11.9
},
{
"Nome_Cliente": "61.855.899 WALTER PONCE JUNIOR",
"Data_Pedido": "30-04-2026",
"Valor_Pedido": 95.2,
"ID_Produto": "919483255",
"Descricao_Produto": "BASE LISA CAMISETA COR PEROLA TAMANHO - G",
"Quantidade": 1,
"Valor_Unitario": 11.9
},
{
"Nome_Cliente": "61.855.899 WALTER PONCE JUNIOR",
"Data_Pedido": "30-04-2026",
"Valor_Pedido": 95.2,
"ID_Produto": "919483299",
"Descricao_Produto": "BASE LISA CAMISETA COR PRETO TAMANHO - P",
"Quantidade": 1,
"Valor_Unitario": 11.9
},
{
"Nome_Cliente": "Francieli Campos",
"Data_Pedido": "30-04-2026",
"Valor_Pedido": 224.95,
"ID_Produto": "978761156",
"Descricao_Produto": "IMPRESSÃO DTF PERSONALIZADO 57X100 (10 METROS)",
"Quantidade": 1,
"Valor_Unitario": 199
},
{
"Nome_Cliente": "Daniela Gutierrez",
"Data_Pedido": "30-04-2026",
"Valor_Pedido": 217.45,
"ID_Produto": "951438842",
"Descricao_Produto": "IMPRESSÃO DTF PERSONALIZADO 57X100 (1 METRO)",
"Quantidade": 10,
"Valor_Unitario": 19.9
},
{
"Nome_Cliente": "Joyce Pretel",
"Data_Pedido": "30-04-2026",
"Valor_Pedido": 22.47,
"ID_Produto": "951540701",
"Descricao_Produto": "BONÉ - PRETO",
"Quantidade": 3,
"Valor_Unitario": 7.49
},
{
"Nome_Cliente": "61.855.899 WALTER PONCE JUNIOR",
"Data_Pedido": "30-04-2026",
"Valor_Pedido": 59.7,
"ID_Produto": "951438842",
"Descricao_Produto": "IMPRESSÃO DTF PERSONALIZADO 57X100 (1 METRO)",
"Quantidade": 3,
"Valor_Unitario": 19.9
}
]

File diff suppressed because it is too large Load Diff

View File

@@ -1,54 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { endOfLocalDay, formatDateParam, parseLocalDateInput, rangeForDay, rangeForLastDays, rangeForPreviousDay, startOfLocalDay } from './dateRanges.ts';
const assertRange = (range: { start: Date; end: Date }, start: string, end: string) => {
assert.equal(formatDateParam(range.start), start);
assert.equal(formatDateParam(range.end), end);
assert.equal(range.start.getHours(), 0);
assert.equal(range.start.getMinutes(), 0);
assert.equal(range.start.getSeconds(), 0);
assert.equal(range.start.getMilliseconds(), 0);
assert.equal(range.end.getHours(), 23);
assert.equal(range.end.getMinutes(), 59);
assert.equal(range.end.getSeconds(), 59);
assert.equal(range.end.getMilliseconds(), 999);
};
test('Hoje uses full local calendar-day boundaries', () => {
const today = new Date(2026, 5, 15, 14, 30, 10, 50);
assertRange(rangeForDay(today), '2026-06-15', '2026-06-15');
});
test('Ontem uses the full previous local calendar day', () => {
const today = new Date(2026, 5, 15, 0, 5, 0, 0);
assertRange(rangeForPreviousDay(today), '2026-06-14', '2026-06-14');
});
test('Ultimos 7 dias includes today plus the previous 6 calendar days', () => {
const today = new Date(2026, 5, 15, 22, 10, 0, 0);
assertRange(rangeForLastDays(7, today), '2026-06-09', '2026-06-15');
});
test('Ultimos 30 and 90 dias use inclusive calendar-day ranges', () => {
const today = new Date(2026, 5, 15, 22, 10, 0, 0);
assertRange(rangeForLastDays(30, today), '2026-05-17', '2026-06-15');
assertRange(rangeForLastDays(90, today), '2026-03-18', '2026-06-15');
});
test('custom single-day input parses as local date and can build a full-day range', () => {
const customDate = parseLocalDateInput('2026-06-14');
assert.ok(customDate);
assertRange({ start: startOfLocalDay(customDate), end: endOfLocalDay(customDate) }, '2026-06-14', '2026-06-14');
});
test('API date params are stable YYYY-MM-DD strings', () => {
assert.equal(formatDateParam(new Date(2026, 5, 14, 23, 59, 59, 999)), '2026-06-14');
assert.equal(parseLocalDateInput('14/06/2026'), null);
});

View File

@@ -1,58 +0,0 @@
import type { DateRange } from './types';
export const startOfLocalDay = (date: Date): Date => {
const nextDate = new Date(date);
nextDate.setHours(0, 0, 0, 0);
return nextDate;
};
export const endOfLocalDay = (date: Date): Date => {
const nextDate = new Date(date);
nextDate.setHours(23, 59, 59, 999);
return nextDate;
};
export const rangeForDay = (date: Date): DateRange => ({
start: startOfLocalDay(date),
end: endOfLocalDay(date),
});
export const rangeForLastDays = (days: number, today = new Date()): DateRange => {
const end = endOfLocalDay(today);
const start = startOfLocalDay(today);
start.setDate(start.getDate() - Math.max(days - 1, 0));
return { start, end };
};
export const rangeForPreviousDay = (today = new Date()): DateRange => {
const date = startOfLocalDay(today);
date.setDate(date.getDate() - 1);
return rangeForDay(date);
};
export const parseLocalDateInput = (value: string): Date | null => {
if (!value) return null;
const match = value.match(/^(\d{4})-(\d{2})-(\d{2})$/);
if (!match) return null;
const [, yearValue, monthValue, dayValue] = match;
const date = new Date(Number(yearValue), Number(monthValue) - 1, Number(dayValue));
if (
date.getFullYear() !== Number(yearValue) ||
date.getMonth() !== Number(monthValue) - 1 ||
date.getDate() !== Number(dayValue)
) {
return null;
}
return date;
};
export const formatDateParam = (date: Date): string => {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
};

View File

@@ -1,41 +0,0 @@
const SMALL_WORDS = new Set(['da', 'de', 'do', 'das', 'dos', 'e']);
export const removeTrailingSellerId = (value: string) => {
return value.replace(/\s*#\d+\s*$/, '').trim();
};
export const formatDisplayName = (value: string) => {
const normalized = String(value || '').replace(/\s+/g, ' ').trim();
if (!normalized) return '';
const withoutSellerId = removeTrailingSellerId(normalized);
const withoutNumericPrefix = withoutSellerId.replace(/^\[\d+\]\s*/, '').trim();
const isUppercaseName = /[A-ZÁÀÂÃÉÈÊÍÏÓÔÕÖÚÇÑ]/.test(withoutNumericPrefix) &&
withoutNumericPrefix === withoutNumericPrefix.toUpperCase();
if (!isUppercaseName) return withoutNumericPrefix;
return withoutNumericPrefix
.toLocaleLowerCase('pt-BR')
.split(' ')
.map((word, index) => {
if (index > 0 && SMALL_WORDS.has(word)) return word;
return word.charAt(0).toLocaleUpperCase('pt-BR') + word.slice(1);
})
.join(' ');
};
export const formatColorLabel = (value: string) => {
const normalized = String(value || '').replace(/\s+/g, ' ').trim();
if (!normalized) return '';
if (normalized.toLocaleLowerCase('pt-BR') === 'sem cor') return 'Sem cor';
return normalized
.toLocaleLowerCase('pt-BR')
.split(' ')
.map((word, index) => {
if (index > 0 && SMALL_WORDS.has(word)) return word;
return word.charAt(0).toLocaleUpperCase('pt-BR') + word.slice(1);
})
.join(' ');
};

View File

@@ -1,66 +1,22 @@
@import "tailwindcss";
@theme {
--color-brand-primary: #b8c7d1;
--color-brand-contrast: #070707;
--color-dark-bg: #070707;
--color-brand-primary: #9ECAE1;
--color-dark-bg: #0a0a0a;
--color-dark-card: #141414;
--color-dark-header: #101010;
--color-dark-sidebar: #111111;
--color-dark-border: #282828;
--color-dark-input: #1c1c1c;
--color-dark-text: #f0f0ee;
--color-dark-muted: #929292;
--color-dark-header: #141414;
--color-dark-sidebar: #141414;
--color-dark-border: #222222;
--color-dark-input: #1a1a1a;
--color-dark-text: #ededed;
--color-dark-muted: #888888;
--font-sans: 'Inter', ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
}
@layer base {
:root {
--chart-grid: #222222;
--chart-axis: #888888;
--chart-cursor: rgba(255, 255, 255, 0.055);
--chart-label: #ededed;
--chart-tooltip-bg: #141414;
--chart-tooltip-text: #ededed;
--chart-tooltip-border: transparent;
--chart-detail-bar: #9ECAE1;
}
html[data-theme='offwhite'] {
--color-brand-primary: #3f535a;
--color-brand-contrast: #fbf8f1;
--color-dark-bg: #f3f0e9;
--color-dark-card: #fbf8f1;
--color-dark-header: #f0ece4;
--color-dark-sidebar: #ebe6dc;
--color-dark-border: #d8d0c3;
--color-dark-input: #f1ede5;
--color-dark-text: #242321;
--color-dark-muted: #6f6a61;
--chart-grid: rgba(63, 58, 49, 0.14);
--chart-axis: #756f66;
--chart-cursor: rgba(63, 58, 49, 0.08);
--chart-label: #242321;
--chart-tooltip-bg: #fbf8f1;
--chart-tooltip-text: #242321;
--chart-tooltip-border: #d8d0c3;
--chart-detail-bar: #3f535a;
}
body {
font-family: 'Inter', sans-serif;
background:
radial-gradient(circle at 18% -8%, rgba(255, 255, 255, 0.035), transparent 30rem),
radial-gradient(circle at 88% 4%, rgba(184, 199, 209, 0.025), transparent 34rem),
#070707;
@apply text-dark-text;
}
html[data-theme='offwhite'] body {
background:
radial-gradient(circle at 16% -8%, rgba(255, 255, 255, 0.82), transparent 32rem),
radial-gradient(circle at 88% 0%, rgba(116, 107, 91, 0.10), transparent 36rem),
#f3f0e9;
@apply bg-dark-bg text-dark-text;
}
::-webkit-scrollbar {
@@ -73,227 +29,3 @@
border-radius: 3px;
}
}
@layer components {
.yield-input {
appearance: textfield;
-moz-appearance: textfield;
}
.yield-input::-webkit-inner-spin-button,
.yield-input::-webkit-outer-spin-button {
margin: 0;
appearance: none;
}
.skeleton {
position: relative;
overflow: hidden;
border-radius: 0.75rem;
background: color-mix(in srgb, var(--color-dark-input) 82%, var(--color-dark-text) 18%);
}
.skeleton::after {
content: "";
position: absolute;
inset: 0;
transform: translateX(-100%);
background: linear-gradient(
90deg,
transparent,
color-mix(in srgb, var(--color-dark-text) 14%, transparent),
transparent
);
animation: skeleton-shimmer 1.45s ease-in-out infinite;
}
@keyframes skeleton-shimmer {
100% {
transform: translateX(100%);
}
}
.refresh-progress {
animation: refresh-progress-slide 1.05s ease-in-out infinite;
box-shadow: 0 0 12px color-mix(in srgb, var(--color-brand-primary) 38%, transparent);
}
.refreshing-content {
opacity: 0.72;
transition: opacity 180ms ease;
}
@keyframes refresh-progress-slide {
0% {
transform: translateX(-120%);
}
100% {
transform: translateX(320%);
}
}
.app-shell {
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.018), rgba(255, 255, 255, 0) 24rem),
radial-gradient(circle at 28% -18%, rgba(255, 255, 255, 0.035), transparent 34rem),
radial-gradient(circle at 88% 8%, rgba(184, 199, 209, 0.024), transparent 38rem),
#070707;
}
html[data-theme='offwhite'] .app-shell {
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.52), rgba(255, 255, 255, 0) 24rem),
radial-gradient(circle at 28% -18%, rgba(255, 255, 255, 0.74), transparent 34rem),
radial-gradient(circle at 88% 8%, rgba(116, 107, 91, 0.10), transparent 38rem),
#f3f0e9;
}
.app-content {
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.012), rgba(255, 255, 255, 0) 16rem),
transparent;
}
html[data-theme='offwhite'] .app-content {
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.34), rgba(255, 255, 255, 0) 16rem),
transparent;
}
@media (max-width: 767px) {
html,
body,
#root {
min-width: 0;
overflow-x: hidden;
}
.app-content {
scroll-padding-bottom: 5rem;
-webkit-overflow-scrolling: touch;
}
.app-content .overflow-x-auto {
overscroll-behavior-x: contain;
scrollbar-width: thin;
scrollbar-color: var(--color-dark-border) transparent;
}
.app-content .overflow-x-auto::-webkit-scrollbar {
height: 5px;
}
.app-content input,
.app-content select,
.app-content textarea {
font-size: 16px;
}
}
}
@layer utilities {
html[data-theme='offwhite'] .text-red-300,
html[data-theme='offwhite'] .text-red-400 {
color: #b42318 !important;
}
html[data-theme='offwhite'] .text-amber-100,
html[data-theme='offwhite'] .text-amber-200,
html[data-theme='offwhite'] .text-amber-300,
html[data-theme='offwhite'] .text-amber-400 {
color: #9a6700 !important;
}
html[data-theme='offwhite'] .text-sky-200,
html[data-theme='offwhite'] .text-sky-300,
html[data-theme='offwhite'] .text-sky-400,
html[data-theme='offwhite'] .text-sky-500 {
color: #0369a1 !important;
}
html[data-theme='offwhite'] .text-emerald-300,
html[data-theme='offwhite'] .text-emerald-400,
html[data-theme='offwhite'] .text-emerald-500 {
color: #047857 !important;
}
html[data-theme='offwhite'] .text-purple-300,
html[data-theme='offwhite'] .text-purple-400,
html[data-theme='offwhite'] .text-purple-500 {
color: #7e22ce !important;
}
html[data-theme='offwhite'] .text-zinc-300,
html[data-theme='offwhite'] .text-zinc-400 {
color: #5f5a52 !important;
}
html[data-theme='offwhite'] .bg-red-400\/10,
html[data-theme='offwhite'] .bg-red-500\/10 {
background-color: rgba(180, 35, 24, 0.12) !important;
}
html[data-theme='offwhite'] .bg-amber-400\/10,
html[data-theme='offwhite'] .bg-amber-500\/10 {
background-color: rgba(154, 103, 0, 0.12) !important;
}
html[data-theme='offwhite'] .bg-sky-400\/10,
html[data-theme='offwhite'] .bg-sky-500\/10 {
background-color: rgba(3, 105, 161, 0.12) !important;
}
html[data-theme='offwhite'] .bg-emerald-400\/10,
html[data-theme='offwhite'] .bg-emerald-500\/10 {
background-color: rgba(4, 120, 87, 0.12) !important;
}
html[data-theme='offwhite'] .bg-purple-400\/10,
html[data-theme='offwhite'] .bg-purple-500\/10 {
background-color: rgba(126, 34, 206, 0.12) !important;
}
html[data-theme='offwhite'] .bg-zinc-500\/10 {
background-color: rgba(95, 90, 82, 0.11) !important;
}
html[data-theme='offwhite'] .border-red-400\/25,
html[data-theme='offwhite'] .border-red-400\/30,
html[data-theme='offwhite'] .border-red-400\/35,
html[data-theme='offwhite'] .border-red-500\/30 {
border-color: rgba(180, 35, 24, 0.28) !important;
}
html[data-theme='offwhite'] .border-amber-400\/20,
html[data-theme='offwhite'] .border-amber-400\/25,
html[data-theme='offwhite'] .border-amber-400\/30,
html[data-theme='offwhite'] .border-amber-400\/35,
html[data-theme='offwhite'] .border-amber-500\/30 {
border-color: rgba(154, 103, 0, 0.30) !important;
}
html[data-theme='offwhite'] .border-sky-400\/20,
html[data-theme='offwhite'] .border-sky-400\/25,
html[data-theme='offwhite'] .border-sky-400\/30,
html[data-theme='offwhite'] .border-sky-400\/35 {
border-color: rgba(3, 105, 161, 0.30) !important;
}
html[data-theme='offwhite'] .border-emerald-400\/25,
html[data-theme='offwhite'] .border-emerald-400\/30,
html[data-theme='offwhite'] .border-emerald-400\/35,
html[data-theme='offwhite'] .border-emerald-500\/30 {
border-color: rgba(4, 120, 87, 0.30) !important;
}
html[data-theme='offwhite'] .border-purple-400\/25,
html[data-theme='offwhite'] .border-purple-400\/30 {
border-color: rgba(126, 34, 206, 0.28) !important;
}
html[data-theme='offwhite'] .border-zinc-500\/30,
html[data-theme='offwhite'] .border-zinc-500\/35 {
border-color: rgba(95, 90, 82, 0.28) !important;
}
}

View File

@@ -1,4 +1,3 @@
import '@vitejs/plugin-react/preamble'
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { HashRouter } from 'react-router-dom'

View File

@@ -1,754 +0,0 @@
import { useEffect, useMemo, useState, type FormEvent } from 'react';
import {
AlertTriangle,
AtSign,
Check,
Copy,
Edit3,
KeyRound,
Loader2,
Mail,
Plus,
RefreshCw,
Search,
Trash2,
User as UserIcon,
UserCheck,
UserPlus,
UserX,
X
} from 'lucide-react';
import { createUser, deleteUser as deleteManagedUser, fetchUsers, updateUser } from '../dataService';
import type { ManagedUser } from '../types';
type StatusFilter = 'all' | 'active' | 'inactive';
type PasswordMode = 'auto' | 'manual';
type Notice = {
tone: 'success' | 'warning' | 'error';
text: string;
temporaryPassword?: string;
};
const statusOptions: Array<{ key: StatusFilter; label: string }> = [
{ key: 'all', label: 'Todos' },
{ key: 'active', label: 'Ativos' },
{ key: 'inactive', label: 'Inativos' }
];
const roleLabels: Record<string, string> = {
admin: 'Admin',
supervisor: 'Supervisor',
producao: 'Produção',
operacao: 'Operação',
vendedor: 'Vendedor',
financeiro: 'Financeiro',
user: 'Usuário'
};
const inputWrapClassName = 'flex h-11 items-center gap-3 rounded-lg border border-dark-border bg-dark-input px-3 transition-colors focus-within:border-brand-primary focus-within:ring-1 focus-within:ring-brand-primary';
const inputClassName = 'min-w-0 flex-1 bg-transparent text-sm font-semibold text-dark-text outline-none placeholder:text-dark-muted';
const getInitials = (name: string) => {
const parts = name.trim().split(/\s+/).filter(Boolean);
if (!parts.length) return '?';
return parts.slice(0, 2).map((part) => part[0]).join('').toUpperCase();
};
const getUserRoleLabel = (user: ManagedUser) => {
const role = String((user as ManagedUser & { role?: string }).role || 'user').toLowerCase();
return roleLabels[role] || role;
};
const AdminUsers = () => {
const [users, setUsers] = useState<ManagedUser[]>([]);
const [searchTerm, setSearchTerm] = useState('');
const [statusFilter, setStatusFilter] = useState<StatusFilter>('all');
const [isLoading, setIsLoading] = useState(true);
const [isModalOpen, setIsModalOpen] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [editingUser, setEditingUser] = useState<ManagedUser | null>(null);
const [userToDelete, setUserToDelete] = useState<ManagedUser | null>(null);
const [passwordMode, setPasswordMode] = useState<PasswordMode>('auto');
const [isActive, setIsActive] = useState(true);
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [notice, setNotice] = useState<Notice | null>(null);
const [copyLabel, setCopyLabel] = useState('Copiar');
const activeCount = useMemo(() => users.filter((user) => user.isActive).length, [users]);
const inactiveCount = users.length - activeCount;
const filteredUsers = useMemo(() => {
const query = searchTerm.trim().toLowerCase();
return users.filter((user) => {
const matchesStatus =
statusFilter === 'all' ||
(statusFilter === 'active' && user.isActive) ||
(statusFilter === 'inactive' && !user.isActive);
const matchesQuery =
!query ||
user.name.toLowerCase().includes(query) ||
user.email.toLowerCase().includes(query);
return matchesStatus && matchesQuery;
});
}, [searchTerm, statusFilter, users]);
const hasFilters = searchTerm.trim() !== '' || statusFilter !== 'all';
const loadUsers = async () => {
setIsLoading(true);
try {
setUsers(await fetchUsers());
} finally {
setIsLoading(false);
}
};
useEffect(() => {
// User management needs an initial API load when the admin page opens.
// eslint-disable-next-line react-hooks/set-state-in-effect
void loadUsers();
}, []);
const resetForm = () => {
setName('');
setEmail('');
setPassword('');
setPasswordMode('auto');
setIsActive(true);
setEditingUser(null);
setNotice(null);
setCopyLabel('Copiar');
};
const closeModal = () => {
if (isSubmitting) return;
setIsModalOpen(false);
resetForm();
};
const clearFilters = () => {
setSearchTerm('');
setStatusFilter('all');
};
const openCreateModal = () => {
resetForm();
setIsModalOpen(true);
};
const openEditModal = (user: ManagedUser) => {
setEditingUser(user);
setName(user.name);
setEmail(user.email);
setIsActive(user.isActive);
setPassword('');
setPasswordMode('auto');
setNotice(null);
setCopyLabel('Copiar');
setIsModalOpen(true);
};
const handleCreateUser = async (event: FormEvent) => {
event.preventDefault();
setIsSubmitting(true);
setNotice(null);
setCopyLabel('Copiar');
try {
const result = await createUser({
name,
email,
password: passwordMode === 'manual' ? password : undefined
});
setUsers((currentUsers) => [result.user, ...currentUsers]);
if (result.temporaryPassword) {
setNotice({
tone: 'success',
text: 'Acesso criado. Copie a senha gerada antes de fechar.',
temporaryPassword: result.temporaryPassword
});
setName('');
setEmail('');
setPassword('');
setPasswordMode('auto');
} else {
setNotice({
tone: 'success',
text: 'Acesso criado com a senha definida.'
});
window.setTimeout(() => {
setIsModalOpen(false);
resetForm();
}, 900);
}
} catch (error) {
setNotice({
tone: 'error',
text: error instanceof Error ? error.message : 'Não foi possível criar o acesso.'
});
} finally {
setIsSubmitting(false);
}
};
const handleUpdateUser = async (event: FormEvent) => {
event.preventDefault();
if (!editingUser) return;
setIsSubmitting(true);
setNotice(null);
try {
const updatedUser = await updateUser(editingUser.id, {
name,
email,
isActive,
password: passwordMode === 'manual' ? password : undefined
});
setUsers((currentUsers) => currentUsers.map((user) => user.id === updatedUser.id ? updatedUser : user));
setNotice({
tone: 'success',
text: 'Usuário atualizado.'
});
window.setTimeout(() => {
setIsModalOpen(false);
resetForm();
}, 700);
} catch (error) {
setNotice({
tone: 'error',
text: error instanceof Error ? error.message : 'Não foi possível editar o usuário.'
});
} finally {
setIsSubmitting(false);
}
};
const handleDeleteUser = async () => {
if (!userToDelete) return;
setIsSubmitting(true);
try {
await deleteManagedUser(userToDelete.id);
setUsers((currentUsers) => currentUsers.filter((user) => user.id !== userToDelete.id));
setUserToDelete(null);
} catch (error) {
setNotice({
tone: 'error',
text: error instanceof Error ? error.message : 'Não foi possível excluir o usuário.'
});
} finally {
setIsSubmitting(false);
}
};
const copyTemporaryPassword = async () => {
if (!notice?.temporaryPassword) return;
try {
await navigator.clipboard.writeText(notice.temporaryPassword);
setCopyLabel('Copiado');
window.setTimeout(() => setCopyLabel('Copiar'), 1600);
} catch {
setCopyLabel('Falhou');
}
};
const noticeClass = notice?.tone === 'error'
? 'border-red-500/30 bg-red-500/10 text-red-300'
: notice?.tone === 'warning'
? 'border-amber-500/30 bg-amber-500/10 text-amber-200'
: 'border-emerald-500/30 bg-emerald-500/10 text-emerald-300';
const statusCounts: Record<StatusFilter, number> = {
all: users.length,
active: activeCount,
inactive: inactiveCount
};
const stats = [
{ label: 'Total', value: users.length, icon: UserIcon, tone: 'text-dark-text', detail: 'acessos cadastrados' },
{ label: 'Ativos', value: activeCount, icon: UserCheck, tone: 'text-emerald-300', detail: 'podem entrar agora' },
{ label: 'Inativos', value: inactiveCount, icon: UserX, tone: 'text-red-300', detail: 'bloqueados no login' },
{ label: 'Exibindo', value: filteredUsers.length, icon: Search, tone: 'text-brand-primary', detail: hasFilters ? 'após filtros' : 'sem filtros' }
];
return (
<div className="space-y-5">
<header className="flex flex-col gap-4 xl:flex-row xl:items-end xl:justify-between">
<div className="max-w-3xl">
<h1 className="text-3xl font-bold text-dark-text">Usuários</h1>
<p className="mt-2 text-sm leading-6 text-dark-muted">
Gerencie acessos individuais, senhas iniciais e bloqueios de entrada no painel.
</p>
</div>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<button
type="button"
onClick={() => void loadUsers()}
disabled={isLoading}
className="inline-flex h-10 cursor-pointer items-center justify-center gap-2 rounded-lg border border-dark-border bg-dark-card px-4 text-sm font-bold text-dark-text transition-colors hover:border-brand-primary disabled:cursor-not-allowed disabled:opacity-50"
>
<RefreshCw className={`h-4 w-4 ${isLoading ? 'animate-spin' : ''}`} />
Atualizar
</button>
<button
type="button"
onClick={openCreateModal}
className="inline-flex h-10 cursor-pointer items-center justify-center gap-2 rounded-lg bg-brand-primary px-4 text-sm font-bold text-brand-contrast transition-colors hover:bg-brand-primary/90"
>
<UserPlus className="h-4 w-4" />
Novo acesso
</button>
</div>
</header>
<section className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-4">
{stats.map((stat) => {
const Icon = stat.icon;
return (
<div key={stat.label} className="rounded-2xl border border-dark-border bg-dark-card p-4 shadow-sm">
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-xs font-bold uppercase tracking-widest text-dark-muted">{stat.label}</p>
<p className={`mt-2 text-2xl font-bold ${stat.tone}`}>{stat.value}</p>
<p className="mt-1 text-xs font-semibold text-dark-muted">{stat.detail}</p>
</div>
<div className="flex h-10 w-10 items-center justify-center rounded-lg border border-dark-border bg-dark-input text-dark-muted">
<Icon className="h-5 w-5" />
</div>
</div>
</div>
);
})}
</section>
<section className="overflow-hidden rounded-2xl border border-dark-border bg-dark-card shadow-sm">
<div className="border-b border-dark-border p-4">
<div className="flex flex-col gap-4 xl:flex-row xl:items-center xl:justify-between">
<div>
<h2 className="text-base font-bold text-dark-text">Acessos cadastrados</h2>
<p className="mt-1 text-sm text-dark-muted">Busque, filtre e edite os usuários do painel.</p>
</div>
<div className="flex flex-col gap-3 lg:flex-row lg:items-center">
<div className="flex h-10 min-w-0 items-center gap-2 rounded-lg border border-dark-border bg-dark-input px-3 lg:w-80">
<Search className="h-4 w-4 shrink-0 text-dark-muted" />
<input
type="search"
value={searchTerm}
onChange={(event) => setSearchTerm(event.target.value)}
className="min-w-0 flex-1 bg-transparent text-sm font-semibold text-dark-text outline-none placeholder:text-dark-muted"
placeholder="Buscar nome ou e-mail"
/>
{searchTerm && (
<button
type="button"
onClick={() => setSearchTerm('')}
className="flex h-6 w-6 cursor-pointer items-center justify-center rounded-md text-dark-muted transition-colors hover:bg-dark-card hover:text-dark-text"
aria-label="Limpar busca"
title="Limpar busca"
>
<X className="h-3.5 w-3.5" />
</button>
)}
</div>
<div className="grid grid-cols-3 rounded-lg border border-dark-border bg-dark-input p-1">
{statusOptions.map((option) => (
<button
key={option.key}
type="button"
onClick={() => setStatusFilter(option.key)}
className={`h-8 cursor-pointer rounded-md px-3 text-sm font-semibold transition-colors ${
statusFilter === option.key
? 'bg-dark-card text-dark-text shadow-sm'
: 'text-dark-muted hover:text-dark-text'
}`}
>
{option.label}
<span className="ml-1 text-xs opacity-70">{statusCounts[option.key]}</span>
</button>
))}
</div>
</div>
</div>
</div>
{isLoading ? (
<div className="divide-y divide-dark-border" aria-label="Carregando usuários">
{Array.from({ length: 5 }).map((_, index) => (
<div key={`user-skeleton-${index}`} className="grid grid-cols-[1.4fr_1fr_120px_140px_88px] gap-5 px-5 py-4">
{Array.from({ length: 5 }).map((__, column) => (
<div key={`user-skeleton-${index}-${column}`} className="skeleton h-4" />
))}
</div>
))}
</div>
) : users.length === 0 ? (
<div className="flex min-h-[360px] flex-col items-center justify-center px-6 py-12 text-center">
<div className="mb-5 flex h-14 w-14 items-center justify-center rounded-2xl border border-dark-border bg-dark-input text-brand-primary">
<UserPlus className="h-7 w-7" />
</div>
<p className="text-lg font-bold text-dark-text">Nenhum usuário cadastrado</p>
<p className="mt-2 max-w-md text-sm leading-6 text-dark-muted">Crie o primeiro acesso individual para tirar o uso compartilhado do login administrativo.</p>
<button
type="button"
onClick={openCreateModal}
className="mt-5 inline-flex h-10 cursor-pointer items-center justify-center gap-2 rounded-lg bg-brand-primary px-4 text-sm font-bold text-brand-contrast transition-colors hover:bg-brand-primary/90"
>
<UserPlus className="h-4 w-4" />
Criar primeiro acesso
</button>
</div>
) : filteredUsers.length === 0 ? (
<div className="flex min-h-[320px] flex-col items-center justify-center px-6 py-12 text-center">
<div className="mb-5 flex h-14 w-14 items-center justify-center rounded-2xl border border-dark-border bg-dark-input text-dark-muted">
<Search className="h-7 w-7" />
</div>
<p className="text-lg font-bold text-dark-text">Nenhum resultado</p>
<p className="mt-2 max-w-md text-sm leading-6 text-dark-muted">Não encontramos usuários com os filtros atuais.</p>
<button
type="button"
onClick={clearFilters}
className="mt-5 inline-flex h-10 cursor-pointer items-center justify-center rounded-lg border border-dark-border px-4 text-sm font-bold text-dark-text transition-colors hover:border-brand-primary"
>
Limpar filtros
</button>
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full min-w-[900px] table-fixed text-left">
<thead className="border-b border-dark-border bg-dark-input/35 text-xs uppercase tracking-widest text-dark-muted">
<tr>
<th className="w-[34%] px-5 py-3 font-bold">Usuário</th>
<th className="w-[24%] px-5 py-3 font-bold">E-mail</th>
<th className="w-[16%] px-5 py-3 font-bold">Perfil</th>
<th className="w-[14%] px-5 py-3 font-bold">Status</th>
<th className="w-[12%] px-5 py-3 text-right font-bold">Ações</th>
</tr>
</thead>
<tbody className="divide-y divide-dark-border">
{filteredUsers.map((user) => (
<tr key={user.id} className="text-sm transition-colors hover:bg-dark-input/45">
<td className="px-5 py-4">
<div className="flex min-w-0 items-center gap-3">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-brand-primary/10 text-sm font-bold text-brand-primary">
{getInitials(user.name)}
</div>
<div className="min-w-0">
<p className="truncate font-bold text-dark-text">{user.name}</p>
<p className="mt-0.5 text-xs font-semibold text-dark-muted">ID {user.id}</p>
</div>
</div>
</td>
<td className="px-5 py-4">
<div className="flex min-w-0 items-center gap-2 text-dark-muted">
<Mail className="h-4 w-4 shrink-0" />
<span className="truncate font-semibold">{user.email}</span>
</div>
</td>
<td className="px-5 py-4">
<span className="inline-flex rounded-full border border-brand-primary/20 bg-brand-primary/10 px-2.5 py-1 text-xs font-bold text-brand-primary">
{getUserRoleLabel(user)}
</span>
</td>
<td className="px-5 py-4">
<span className={`inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-bold ${
user.isActive ? 'bg-emerald-500/10 text-emerald-300' : 'bg-red-500/10 text-red-300'
}`}>
<span className={`h-1.5 w-1.5 rounded-full ${user.isActive ? 'bg-emerald-300' : 'bg-red-300'}`} />
{user.isActive ? 'Ativo' : 'Inativo'}
</span>
</td>
<td className="px-5 py-4">
<div className="flex justify-end gap-2">
<button
type="button"
onClick={() => openEditModal(user)}
className="flex h-9 w-9 cursor-pointer items-center justify-center rounded-lg border border-dark-border text-dark-muted transition-colors hover:border-brand-primary/50 hover:text-brand-primary"
aria-label={`Editar ${user.name}`}
title="Editar"
>
<Edit3 className="h-4 w-4" />
</button>
<button
type="button"
onClick={() => {
setNotice(null);
setUserToDelete(user);
}}
className="flex h-9 w-9 cursor-pointer items-center justify-center rounded-lg border border-dark-border text-dark-muted transition-colors hover:border-red-400/50 hover:text-red-300"
aria-label={`Excluir ${user.name}`}
title="Excluir"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
{isModalOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center overflow-y-auto bg-black/70 px-4 py-6">
<div className="w-full max-w-2xl overflow-hidden rounded-2xl border border-dark-border bg-dark-card shadow-2xl">
<div className="flex items-start justify-between gap-4 border-b border-dark-border px-5 py-4">
<div className="flex items-start gap-3">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl border border-dark-border bg-dark-input text-brand-primary">
{editingUser ? <Edit3 className="h-5 w-5" /> : <UserPlus className="h-5 w-5" />}
</div>
<div>
<h2 className="text-lg font-bold text-dark-text">{editingUser ? 'Editar usuário' : 'Novo acesso'}</h2>
<p className="mt-1 text-sm leading-5 text-dark-muted">
{editingUser ? 'Atualize identificação, status e senha.' : 'Crie uma credencial individual para o painel.'}
</p>
</div>
</div>
<button
type="button"
onClick={closeModal}
className="flex h-9 w-9 cursor-pointer items-center justify-center rounded-lg text-dark-muted transition-colors hover:bg-dark-input hover:text-dark-text"
aria-label="Fechar"
title="Fechar"
>
<X className="h-5 w-5" />
</button>
</div>
<form onSubmit={editingUser ? handleUpdateUser : handleCreateUser}>
<div className="space-y-5 p-5">
<section>
<div className="mb-3">
<h3 className="text-sm font-bold text-dark-text">Identificação</h3>
<p className="mt-1 text-xs font-semibold text-dark-muted">Use um nome reconhecível e um e-mail individual.</p>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<label className="block">
<span className="mb-2 block text-xs font-bold uppercase tracking-widest text-dark-muted">Nome</span>
<div className={inputWrapClassName}>
<UserIcon className="h-4 w-4 shrink-0 text-dark-muted" />
<input
type="text"
value={name}
onChange={(event) => setName(event.target.value)}
className={inputClassName}
placeholder="Nome completo"
required
autoFocus
/>
</div>
</label>
<label className="block">
<span className="mb-2 block text-xs font-bold uppercase tracking-widest text-dark-muted">E-mail</span>
<div className={inputWrapClassName}>
<AtSign className="h-4 w-4 shrink-0 text-dark-muted" />
<input
type="email"
value={email}
onChange={(event) => setEmail(event.target.value)}
className={inputClassName}
placeholder="usuario@empresa.com"
required
/>
</div>
</label>
</div>
</section>
<section>
<div className="mb-3">
<h3 className="text-sm font-bold text-dark-text">{editingUser ? 'Senha' : 'Senha inicial'}</h3>
<p className="mt-1 text-xs font-semibold text-dark-muted">
{editingUser ? 'Mantenha a senha atual ou defina uma nova.' : 'Gere uma senha temporária ou defina uma senha manual.'}
</p>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<button
type="button"
onClick={() => setPasswordMode('auto')}
className={`cursor-pointer rounded-xl border p-4 text-left transition-colors ${
passwordMode === 'auto'
? 'border-brand-primary/50 bg-brand-primary/10 text-dark-text'
: 'border-dark-border bg-dark-input text-dark-muted hover:border-brand-primary/35 hover:text-dark-text'
}`}
>
<span className="flex items-center gap-2 text-sm font-bold">
<KeyRound className="h-4 w-4" />
{editingUser ? 'Manter senha' : 'Gerar senha'}
</span>
<span className="mt-1 block text-xs font-semibold opacity-80">
{editingUser ? 'Não altera a credencial atual.' : 'Mostra uma senha para copiar após criar.'}
</span>
</button>
<button
type="button"
onClick={() => setPasswordMode('manual')}
className={`cursor-pointer rounded-xl border p-4 text-left transition-colors ${
passwordMode === 'manual'
? 'border-brand-primary/50 bg-brand-primary/10 text-dark-text'
: 'border-dark-border bg-dark-input text-dark-muted hover:border-brand-primary/35 hover:text-dark-text'
}`}
>
<span className="flex items-center gap-2 text-sm font-bold">
<Edit3 className="h-4 w-4" />
{editingUser ? 'Alterar senha' : 'Definir senha'}
</span>
<span className="mt-1 block text-xs font-semibold opacity-80">
Use quando a senha já será combinada fora do sistema.
</span>
</button>
</div>
{passwordMode === 'manual' && (
<div className="mt-3">
<div className={inputWrapClassName}>
<KeyRound className="h-4 w-4 shrink-0 text-dark-muted" />
<input
type="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
className={inputClassName}
placeholder="Mínimo de 6 caracteres"
minLength={6}
required
/>
</div>
</div>
)}
</section>
{editingUser && (
<label className="flex cursor-pointer items-center justify-between gap-4 rounded-xl border border-dark-border bg-dark-input px-4 py-3">
<div>
<span className="block text-sm font-bold text-dark-text">Usuário ativo</span>
<span className="mt-0.5 block text-xs font-semibold text-dark-muted">Usuários inativos não conseguem entrar.</span>
</div>
<input
type="checkbox"
checked={isActive}
onChange={(event) => setIsActive(event.target.checked)}
className="h-5 w-5 cursor-pointer accent-brand-primary"
/>
</label>
)}
{notice && (
<div className={`rounded-xl border px-3 py-3 text-sm font-semibold ${noticeClass}`}>
<div className="flex items-start gap-2">
{notice.tone === 'error' ? <X className="mt-0.5 h-4 w-4 shrink-0" /> : <Check className="mt-0.5 h-4 w-4 shrink-0" />}
<div className="min-w-0 flex-1">
<p>{notice.text}</p>
{notice.temporaryPassword && (
<div className="mt-3 flex flex-col gap-2 sm:flex-row">
<code className="min-w-0 flex-1 truncate rounded-lg bg-black/25 px-3 py-2 text-amber-100">
{notice.temporaryPassword}
</code>
<button
type="button"
onClick={copyTemporaryPassword}
className="inline-flex h-9 cursor-pointer items-center justify-center gap-2 rounded-lg border border-amber-400/30 px-3 text-xs font-bold text-amber-100 transition-colors hover:bg-amber-400/10"
>
<Copy className="h-3.5 w-3.5" />
{copyLabel}
</button>
</div>
)}
</div>
</div>
</div>
)}
</div>
<div className="flex flex-col-reverse gap-2 border-t border-dark-border bg-dark-input/35 px-5 py-4 sm:flex-row sm:justify-end">
<button
type="button"
onClick={closeModal}
disabled={isSubmitting}
className="h-10 cursor-pointer rounded-lg border border-dark-border px-4 text-sm font-bold text-dark-muted transition-colors hover:text-dark-text disabled:cursor-not-allowed disabled:opacity-50"
>
Cancelar
</button>
<button
type="submit"
disabled={isSubmitting}
className="inline-flex h-10 cursor-pointer items-center justify-center gap-2 rounded-lg bg-brand-primary px-4 text-sm font-bold text-brand-contrast transition-colors hover:bg-brand-primary/90 disabled:cursor-not-allowed disabled:opacity-50"
>
{isSubmitting ? <Loader2 className="h-4 w-4 animate-spin" /> : <Plus className="h-4 w-4" />}
{editingUser ? 'Salvar alterações' : 'Criar acesso'}
</button>
</div>
</form>
</div>
</div>
)}
{userToDelete && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 px-4 py-6">
<div className="w-full max-w-md overflow-hidden rounded-2xl border border-dark-border bg-dark-card shadow-2xl">
<div className="flex items-start gap-3 border-b border-dark-border px-5 py-4">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl border border-red-500/25 bg-red-500/10 text-red-300">
<AlertTriangle className="h-5 w-5" />
</div>
<div>
<h2 className="text-lg font-bold text-dark-text">Excluir usuário</h2>
<p className="mt-1 text-sm leading-5 text-dark-muted">Esta ação remove o acesso imediatamente.</p>
</div>
</div>
<div className="space-y-4 p-5">
<div className="rounded-xl border border-dark-border bg-dark-input p-4">
<p className="font-bold text-dark-text">{userToDelete.name}</p>
<p className="mt-1 text-sm font-semibold text-dark-muted">{userToDelete.email}</p>
</div>
{notice?.tone === 'error' && (
<div className={`rounded-xl border px-3 py-3 text-sm font-semibold ${noticeClass}`}>
{notice.text}
</div>
)}
<div className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<button
type="button"
onClick={() => {
if (isSubmitting) return;
setUserToDelete(null);
setNotice(null);
}}
disabled={isSubmitting}
className="h-10 cursor-pointer rounded-lg border border-dark-border px-4 text-sm font-bold text-dark-muted transition-colors hover:text-dark-text disabled:cursor-not-allowed disabled:opacity-50"
>
Cancelar
</button>
<button
type="button"
onClick={handleDeleteUser}
disabled={isSubmitting}
className="inline-flex h-10 cursor-pointer items-center justify-center gap-2 rounded-lg bg-red-500 px-4 text-sm font-bold text-white transition-colors hover:bg-red-400 disabled:cursor-not-allowed disabled:opacity-50"
>
{isSubmitting ? <Loader2 className="h-4 w-4 animate-spin" /> : <Trash2 className="h-4 w-4" />}
Excluir
</button>
</div>
</div>
</div>
</div>
)}
</div>
);
};
export default AdminUsers;

View File

@@ -1,450 +0,0 @@
import { useEffect, useMemo, useState } from 'react';
import { AlertTriangle, CheckCircle2, Clock, Megaphone, RefreshCw, RotateCcw, Send, Users, XCircle } from 'lucide-react';
import RefreshStatus from '../components/RefreshStatus';
import type { CampaignGroup, CampaignPreview, CampaignProcessSummary, CampaignQueueSummary, CampaignStatus } from '../types';
import { fetchCampaignPreview, fetchCampaigns, processCampaignsNow, retryCampaignGroup } from '../dataService';
const statusLabels: Record<CampaignStatus, string> = {
pending: 'Pendente',
processing: 'Processando',
sent: 'Enviada',
failed: 'Falhou',
skipped: 'Ignorada'
};
type StatusVisual = {
accent: string;
bg: string;
border: string;
text: string;
};
type PreviewFilter = 'all' | 'ready' | 'near' | 'below';
const statusStyles: Record<CampaignStatus, StatusVisual> = {
pending: { accent: '#FFC247', bg: 'rgba(255, 194, 71, 0.10)', border: 'rgba(255, 194, 71, 0.28)', text: '#FFC247' },
processing: { accent: '#25C2FF', bg: 'rgba(37, 194, 255, 0.10)', border: 'rgba(37, 194, 255, 0.28)', text: '#25C2FF' },
sent: { accent: '#52DFA0', bg: 'rgba(82, 223, 160, 0.10)', border: 'rgba(82, 223, 160, 0.28)', text: '#52DFA0' },
failed: { accent: '#FF7A9B', bg: 'rgba(255, 122, 155, 0.11)', border: 'rgba(255, 122, 155, 0.32)', text: '#FF7A9B' },
skipped: { accent: '#8c9298', bg: 'rgba(140, 146, 152, 0.08)', border: 'rgba(140, 146, 152, 0.24)', text: '#b3b7bb' }
};
const statusIcons: Record<CampaignStatus, typeof Clock> = {
pending: Clock,
processing: RefreshCw,
sent: CheckCircle2,
failed: XCircle,
skipped: AlertTriangle
};
const formatDate = (value?: string | null) => {
if (!value) return '-';
return new Date(value).toLocaleString('pt-BR');
};
const formatDelta = (value: number) => `${value} un.`;
const NEAR_THRESHOLD_PERCENT = 80;
const statusPillStyle = (status: CampaignStatus) => {
const style = statusStyles[status];
return {
backgroundColor: style.bg,
borderColor: style.border,
color: style.text
};
};
const CampaignsSkeleton = () => (
<div className="space-y-6" aria-label="Carregando campanhas">
<div className="grid grid-cols-1 md:grid-cols-5 gap-4">
{[0, 1, 2, 3, 4].map(item => (
<div key={`campaign-kpi-skeleton-${item}`} className="bg-dark-card border border-dark-border rounded-2xl p-4">
<div className="flex items-center justify-between">
<div className="skeleton h-3 w-24" />
<div className="skeleton h-4 w-4 rounded-full" />
</div>
<div className="skeleton mt-4 h-7 w-12" />
</div>
))}
</div>
<div className="grid grid-cols-1 xl:grid-cols-2 gap-6">
{[0, 1].map(section => (
<div key={`campaign-card-skeleton-${section}`} className="bg-dark-card border border-dark-border rounded-2xl p-6">
<div className="skeleton h-5 w-48" />
<div className="mt-5 space-y-3">
{[0, 1, 2, 3].map(item => (
<div key={`campaign-card-row-skeleton-${section}-${item}`} className="rounded-xl border border-dark-border p-3">
<div className="skeleton h-4 w-3/5" />
<div className="mt-3 skeleton h-2 w-full" />
</div>
))}
</div>
</div>
))}
</div>
<div className="bg-dark-card border border-dark-border rounded-2xl overflow-hidden">
<div className="border-b border-dark-border p-4">
<div className="grid grid-cols-[1.5fr_130px_90px_80px_90px_150px_110px] gap-5">
{[0, 1, 2, 3, 4, 5, 6].map(item => (
<div key={`campaign-table-head-skeleton-${item}`} className="skeleton h-3" />
))}
</div>
</div>
<div className="p-6 space-y-5">
{[0, 1, 2, 3, 4, 5].map(row => (
<div key={`campaign-table-row-skeleton-${row}`} className="grid grid-cols-[1.5fr_130px_90px_80px_90px_150px_110px] gap-5">
{[0, 1, 2, 3, 4, 5, 6].map(column => (
<div key={`campaign-table-cell-skeleton-${row}-${column}`} className="skeleton h-4" />
))}
</div>
))}
</div>
</div>
</div>
);
const Campaigns = () => {
const [summary, setSummary] = useState<CampaignQueueSummary | null>(null);
const [preview, setPreview] = useState<CampaignPreview | null>(null);
const [processResult, setProcessResult] = useState<CampaignProcessSummary | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [isProcessing, setIsProcessing] = useState(false);
const [previewFilter, setPreviewFilter] = useState<PreviewFilter>('all');
const loadCampaigns = async () => {
setIsLoading(true);
const [campaignsData, previewData] = await Promise.all([
fetchCampaigns(),
fetchCampaignPreview()
]);
setSummary(campaignsData);
setPreview(previewData);
setIsLoading(false);
};
useEffect(() => {
// Campaign state is loaded from the backend after the protected route mounts.
// eslint-disable-next-line react-hooks/set-state-in-effect
void loadCampaigns();
}, []);
const groupedCounts = useMemo(() => {
const counts: Record<CampaignStatus, number> = {
pending: 0,
processing: 0,
sent: 0,
failed: 0,
skipped: 0
};
summary?.groups.forEach(group => {
counts[group.status] += 1;
});
return counts;
}, [summary]);
const threshold = summary?.threshold ?? preview?.threshold ?? 100;
const previewProducts = useMemo(() => {
const ready = (preview?.readyProducts || []).map(product => ({ ...product, isReady: true }));
const below = (preview?.belowThresholdProducts || []).map(product => ({ ...product, isReady: false }));
return [...ready, ...below].sort((a, b) => Number(b.isReady) - Number(a.isReady) || b.total_delta - a.total_delta);
}, [preview]);
const previewProductRows = useMemo(() => {
return previewProducts.map(product => {
const progress = threshold ? Math.min(100, (product.total_delta / threshold) * 100) : 0;
return {
...product,
progress,
isNear: !product.isReady && progress >= NEAR_THRESHOLD_PERCENT
};
});
}, [previewProducts, threshold]);
const filteredPreviewProducts = useMemo(() => {
switch (previewFilter) {
case 'ready':
return previewProductRows.filter(product => product.isReady);
case 'near':
return previewProductRows.filter(product => product.isNear);
case 'below':
return previewProductRows.filter(product => !product.isReady);
default:
return previewProductRows;
}
}, [previewFilter, previewProductRows]);
const previewFilterOptions = useMemo(() => [
{ key: 'all' as const, label: 'Todos', count: previewProductRows.length },
{ key: 'ready' as const, label: 'Prontos', count: previewProductRows.filter(product => product.isReady).length },
{ key: 'near' as const, label: 'Quase prontos', count: previewProductRows.filter(product => product.isNear).length },
{ key: 'below' as const, label: 'Abaixo', count: previewProductRows.filter(product => !product.isReady).length }
], [previewProductRows]);
const resultStats = processResult ? [
{ label: 'Processados', value: processResult.claimed, color: '#25C2FF' },
{ label: 'Enviados', value: processResult.sentGroups, color: '#52DFA0' },
{ label: 'Falhas', value: processResult.failedGroups, color: '#FF7A9B' },
{ label: 'Abaixo do limite', value: processResult.pendingBelowThresholdGroups, color: '#FFC247' }
] : [];
const statusSummary = (Object.keys(statusLabels) as CampaignStatus[]).map(status => ({
status,
label: statusLabels[status],
count: groupedCounts[status],
Icon: statusIcons[status],
style: statusStyles[status]
}));
const shouldShowSkeleton = isLoading && !summary && !preview;
const isRefreshing = isLoading && Boolean(summary || preview);
const handleProcessNow = async () => {
setIsProcessing(true);
const result = await processCampaignsNow();
setProcessResult(result);
await loadCampaigns();
setIsProcessing(false);
};
const handleRetry = async (group: CampaignGroup) => {
setIsProcessing(true);
await retryCampaignGroup(group.baseProductName);
await loadCampaigns();
setIsProcessing(false);
};
return (
<div className="space-y-6">
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-4">
<div>
<h1 className="text-2xl font-bold mb-2 text-dark-text">Campanhas</h1>
<p className="text-dark-muted font-medium">Fila de reposição, prévia de envio e histórico das campanhas do WhatsApp.</p>
</div>
<div className="flex flex-wrap gap-3">
<button
onClick={() => void loadCampaigns()}
disabled={isLoading || isProcessing}
className="inline-flex items-center gap-2 bg-dark-card border border-dark-border px-4 py-2.5 rounded-xl hover:border-brand-primary transition-colors text-sm font-medium text-dark-text disabled:opacity-50 cursor-pointer"
>
<RefreshCw size={16} className={isLoading ? 'animate-spin' : ''} />
Atualizar
</button>
<button
onClick={handleProcessNow}
disabled={isProcessing || !preview?.readyProducts.length}
className="inline-flex items-center gap-2 bg-brand-primary text-brand-contrast px-4 py-2.5 rounded-xl hover:opacity-90 transition-opacity text-sm font-bold disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer"
>
<Send size={16} />
Processar agora
</button>
</div>
</div>
<RefreshStatus isRefreshing={isRefreshing || isProcessing} label={isProcessing ? 'Processando campanhas' : 'Atualizando campanhas'} />
{processResult && (
<div className="bg-dark-card border border-dark-border rounded-2xl p-3">
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
{resultStats.map(item => (
<div key={item.label} className="flex items-center justify-between gap-3 rounded-xl bg-dark-input/55 px-3 py-2">
<span className="flex items-center gap-2 text-xs font-bold uppercase tracking-wide text-dark-muted">
<span className="h-2 w-2 rounded-full" style={{ backgroundColor: item.color }} />
{item.label}
</span>
<span className="text-sm font-bold text-dark-text">{item.value}</span>
</div>
))}
</div>
</div>
)}
{shouldShowSkeleton ? (
<CampaignsSkeleton />
) : (
<div className={isRefreshing || isProcessing ? 'refreshing-content space-y-6' : 'space-y-6'} aria-busy={isRefreshing || isProcessing}>
<div className="grid grid-cols-1 md:grid-cols-5 gap-4">
{statusSummary.map(({ status, label, count, Icon, style }) => {
return (
<div key={status} className="bg-dark-card border border-dark-border rounded-2xl p-4">
<div className="flex items-center justify-between mb-3">
<span className="text-xs font-bold uppercase tracking-widest text-dark-muted">{label}</span>
<Icon className="w-4 h-4" style={{ color: style.accent }} />
</div>
<p className="text-2xl font-bold text-dark-text">{count}</p>
</div>
);
})}
</div>
<div className="grid grid-cols-1 xl:grid-cols-2 gap-6">
<div className="bg-dark-card border border-dark-border rounded-2xl p-6">
<div className="flex items-center gap-2 mb-4">
<Megaphone className="w-5 h-5 text-brand-primary" />
<h2 className="text-lg font-bold text-dark-text">Prévia do próximo envio</h2>
</div>
<div className="space-y-4">
<div>
<p className="text-xs font-bold text-dark-muted uppercase tracking-widest mb-1">Produtos prontos</p>
<p className="text-dark-text font-semibold">{preview?.productsText || 'Nenhum produto atingiu o limite ainda.'}</p>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="bg-dark-input rounded-xl p-3 border border-dark-border">
<p className="text-xs text-dark-muted mb-1">Clientes alvo</p>
<p className="text-xl font-bold text-dark-text">{preview?.customerCount ?? 0}</p>
</div>
<div className="bg-dark-input rounded-xl p-3 border border-dark-border">
<p className="text-xs text-dark-muted mb-1">Limite por produto</p>
<p className="text-xl font-bold text-dark-text">{summary?.threshold ?? preview?.threshold ?? 100}</p>
</div>
</div>
<div className="flex flex-wrap gap-2">
{previewFilterOptions.map(option => (
<button
key={option.key}
type="button"
onClick={() => setPreviewFilter(option.key)}
className={`inline-flex h-8 cursor-pointer items-center gap-2 rounded-lg border px-3 text-xs font-bold transition-colors ${
previewFilter === option.key
? 'border-brand-primary bg-brand-primary/10 text-dark-text'
: 'border-dark-border bg-dark-input/45 text-dark-muted hover:text-dark-text'
}`}
>
{option.label}
<span className="rounded-full bg-dark-card px-1.5 py-0.5 text-[10px] text-dark-muted">{option.count}</span>
</button>
))}
</div>
<div className="max-h-[34rem] space-y-2 overflow-y-auto pr-1">
{filteredPreviewProducts.map(product => {
const accent = product.isReady ? statusStyles.sent.accent : statusStyles.pending.accent;
return (
<div
key={product.baseProduct}
className="rounded-xl border border-dark-border bg-dark-input/45 p-3"
>
<div className="flex items-start justify-between gap-4">
<div className="min-w-0">
<p className={`truncate text-sm font-bold ${product.isReady ? 'text-dark-text' : 'text-dark-muted'}`}>
{product.baseProduct}
</p>
<p className="mt-1 text-[11px] font-semibold text-dark-muted">
{formatDelta(product.total_delta)} de {formatDelta(threshold)}
</p>
</div>
<span
className="rounded-full border px-2.5 py-1 text-[11px] font-bold"
style={product.isReady ? statusPillStyle('sent') : statusPillStyle('pending')}
>
{product.isReady ? 'Pronto' : `${Math.round(product.progress)}%`}
</span>
</div>
<div className="mt-3 h-1.5 overflow-hidden rounded-full bg-dark-border/70">
<div className="h-full rounded-full" style={{ width: `${product.progress}%`, backgroundColor: accent }} />
</div>
</div>
);
})}
{!filteredPreviewProducts.length && (
<div className="rounded-xl border border-dark-border bg-dark-input/45 p-4 text-sm font-semibold text-dark-muted">
Nenhum produto nesta visualização.
</div>
)}
</div>
</div>
</div>
<div className="bg-dark-card border border-dark-border rounded-2xl p-6">
<div className="mb-4 flex items-center gap-2">
<Users className="w-5 h-5 text-brand-primary" />
<h2 className="text-lg font-bold text-dark-text">Top clientes da campanha</h2>
</div>
<div className="divide-y divide-dark-border rounded-xl border border-dark-border overflow-hidden">
{(preview?.customersPreview || []).map((customer, index) => (
<div key={`${customer.fone}-${customer.nome}`} className="flex items-center justify-between gap-4 px-4 py-3">
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-dark-input text-xs font-bold text-dark-muted">
{index + 1}
</div>
<div className="min-w-0">
<p className="font-semibold text-dark-text truncate">{customer.nome}</p>
<p className="text-xs text-dark-muted">{customer.fone}</p>
</div>
<span className="ml-auto text-xs font-bold text-brand-primary shrink-0">{customer.total_comprado || 0} un.</span>
</div>
))}
{!preview?.customersPreview.length && (
<div className="p-4 text-sm font-semibold text-dark-muted">Nenhum cliente com telefone válido encontrado.</div>
)}
</div>
</div>
</div>
<div className="bg-dark-card border border-dark-border rounded-2xl overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-left text-sm">
<thead className="bg-dark-header border-b border-dark-border text-dark-muted">
<tr>
<th className="px-6 py-4 font-bold uppercase tracking-wider text-[10px]">Produto</th>
<th className="px-6 py-4 font-bold uppercase tracking-wider text-[10px]">Status</th>
<th className="px-6 py-4 font-bold uppercase tracking-wider text-[10px]">Delta</th>
<th className="px-6 py-4 font-bold uppercase tracking-wider text-[10px]">Itens</th>
<th className="px-6 py-4 font-bold uppercase tracking-wider text-[10px]">Tentativas</th>
<th className="px-6 py-4 font-bold uppercase tracking-wider text-[10px]">Atualizado</th>
<th className="px-6 py-4 font-bold uppercase tracking-wider text-[10px] text-right">Ações</th>
</tr>
</thead>
<tbody className="divide-y divide-dark-border">
{(summary?.groups || []).map(group => {
const Icon = statusIcons[group.status];
return (
<tr key={group.key} className="hover:bg-dark-input/40 transition-colors">
<td className="px-6 py-3">
<p className="font-semibold text-dark-text">{group.baseProductName}</p>
{group.lastError && <p className="text-xs text-red-400 mt-1 max-w-md truncate">{group.lastError}</p>}
</td>
<td className="px-6 py-3">
<span
className="inline-flex items-center gap-1.5 border px-2.5 py-1 rounded-full text-xs font-bold"
style={statusPillStyle(group.status)}
>
<Icon className="w-3.5 h-3.5" />
{statusLabels[group.status]}
</span>
</td>
<td className="px-6 py-3 font-bold text-dark-text">{formatDelta(group.totalDelta)}</td>
<td className="px-6 py-3 text-dark-muted">{group.rowCount}</td>
<td className="px-6 py-3 text-dark-muted">{group.attempts}</td>
<td className="px-6 py-3 text-dark-muted whitespace-nowrap">{formatDate(group.updatedAt)}</td>
<td className="px-6 py-3 text-right">
{(group.status === 'failed' || group.status === 'skipped') && (
<button
onClick={() => void handleRetry(group)}
disabled={isProcessing}
className="inline-flex items-center gap-1.5 text-xs font-bold text-brand-primary hover:opacity-80 disabled:opacity-50 cursor-pointer"
>
<RotateCcw className="w-3.5 h-3.5" />
Reprocessar
</button>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
{!summary?.groups.length && !isLoading && (
<div className="p-8 text-center text-dark-muted">Nenhuma campanha registrada ainda.</div>
)}
</div>
</div>
)}
</div>
);
};
export default Campaigns;

View File

@@ -1,240 +1,55 @@
import { useEffect, useState } from 'react';
import { useMemo } from 'react';
import { useParams, Link, useOutletContext } from 'react-router-dom';
import { User, Tag, Package, DollarSign, Clock, Phone, ChevronDown, ShoppingBag, ReceiptText } from 'lucide-react';
import { AreaChart, Area, BarChart, Bar, Cell, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
import BackButton from '../components/BackButton';
import DateRangePicker from '../components/DateRangePicker';
import PaginationControls from '../components/PaginationControls';
import RefreshStatus from '../components/RefreshStatus';
import type { ClientDetailsAnalytics, DateRange, OrderData } from '../types';
import { fetchClientDetailsAnalytics } from '../dataService';
import { formatDisplayName, removeTrailingSellerId } from '../displayFormatters';
const CHART_GRID_COLOR = 'var(--chart-grid)';
const CHART_AXIS_COLOR = 'var(--chart-axis)';
const CHART_CURSOR_COLOR = 'var(--chart-cursor)';
const CHART_DETAIL_BAR_COLOR = 'var(--chart-detail-bar)';
const WEEKDAY_BAR_COLOR = '#25C2FF';
const HOUR_BAR_COLOR = '#52DFA0';
const formatDateKey = (date: Date) => {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
};
type CustomTooltipProps = {
active?: boolean;
payload?: Array<{ value: number }>;
label?: string;
};
type PatternPoint = {
label: string;
value: number;
};
const getTopPatternPoint = (points: PatternPoint[]) => (
points.reduce<PatternPoint | null>((topPoint, point) => {
if (!topPoint || point.value > topPoint.value) return point;
return topPoint;
}, null)
);
const getPatternShare = (value: number, total: number) => (
total > 0 ? Math.round((value / total) * 100) : 0
);
const CustomTooltip = ({ active, payload, label }: CustomTooltipProps) => {
if (active && payload && payload.length) {
return (
<div className="rounded-xl bg-dark-card p-3 shadow-lg">
<p className="mb-1 font-bold text-brand-primary">{label}</p>
<p className="m-0 text-dark-text">
Gasto: {new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(payload[0].value)}
</p>
</div>
);
}
return null;
};
const PatternTooltip = ({ active, payload, label }: CustomTooltipProps) => {
if (active && payload && payload.length) {
const value = payload[0].value;
return (
<div className="rounded-xl bg-dark-card p-3 shadow-lg">
<p className="mb-1 font-bold text-brand-primary">{label}</p>
<p className="m-0 text-dark-text">
{value} {value === 1 ? 'pedido' : 'pedidos'}
</p>
</div>
);
}
return null;
};
const getOrderMetadata = (order: OrderData) => {
const sellerName = formatDisplayName(removeTrailingSellerId(order.nome_vendedor || ''));
return [
order.cliente_nome_fantasia ? `Fantasia: ${formatDisplayName(order.cliente_nome_fantasia)}` : '',
sellerName ? `Vendedor: ${sellerName}` : '',
order.marketplace ? `Marketplace: ${order.marketplace}` : '',
order.canal_venda ? `Canal: ${order.canal_venda}` : '',
order.numero_ecommerce ? `E-commerce: ${order.numero_ecommerce}` : ''
].filter(Boolean);
};
const ClientDetailsSkeleton = () => (
<div className="space-y-6" aria-label="Carregando cliente">
<div className="flex flex-col gap-4">
<div className="skeleton h-4 w-20" />
<div className="flex flex-col md:flex-row md:items-end justify-between gap-6">
<div className="flex items-center gap-4">
<div className="skeleton h-16 w-16 rounded-2xl" />
<div>
<div className="skeleton h-7 w-56" />
<div className="skeleton mt-3 h-4 w-72" />
</div>
</div>
<div className="skeleton h-10 w-64" />
</div>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-4">
{[0, 1, 2, 3].map(item => (
<div key={`client-details-kpi-skeleton-${item}`} className="bg-dark-card p-5 rounded-2xl border border-dark-border shadow-sm">
<div className="flex justify-between gap-5">
<div className="w-full">
<div className="skeleton h-3 w-32" />
<div className="skeleton mt-3 h-7 w-28" />
</div>
<div className="skeleton h-11 w-11 shrink-0 rounded-xl" />
</div>
</div>
))}
</div>
<div className="bg-white dark:bg-dark-card border border-zinc-200 dark:border-dark-border rounded-2xl p-6 shadow-sm">
<div className="skeleton h-5 w-36" />
<div className="mt-8 skeleton h-[320px] w-full" />
</div>
<section className="space-y-4">
<div className="flex items-start justify-between gap-4">
<div>
<div className="skeleton h-5 w-44" />
<div className="skeleton mt-2 h-4 w-64" />
</div>
<div className="skeleton h-7 w-28 rounded-full" />
</div>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
{[0, 1].map(item => (
<div key={`client-pattern-skeleton-${item}`} className="bg-white dark:bg-dark-card border border-zinc-200 dark:border-dark-border rounded-2xl p-6 shadow-sm">
<div className="skeleton h-4 w-40" />
<div className="mt-5 flex h-56 items-end gap-3">
{[0, 1, 2, 3, 4, 5, 6].map(bar => (
<div key={`client-pattern-bar-skeleton-${item}-${bar}`} className="skeleton flex-1" style={{ height: `${20 + ((bar * 17) % 65)}%` }} />
))}
</div>
</div>
))}
</div>
</section>
<div className="flex flex-col gap-4">
{[0, 1, 2, 3].map(item => (
<div key={`client-order-skeleton-${item}`} className="bg-white dark:bg-dark-card border border-zinc-200 dark:border-dark-border rounded-2xl p-4 shadow-sm">
<div className="flex items-center justify-between gap-4">
<div className="flex min-w-0 items-center gap-3">
<div className="skeleton h-8 w-8 rounded-lg" />
<div>
<div className="skeleton h-4 w-48" />
<div className="skeleton mt-2 h-3 w-32" />
</div>
</div>
<div className="skeleton h-8 w-28" />
</div>
</div>
))}
</div>
</div>
);
import { ArrowLeft, User, Tag, Package, DollarSign, Clock } from 'lucide-react';
import type { OrderData } from '../types';
import { parseOrderDate } from '../dataService';
const ClientDetails = () => {
const { clientToken } = useParams<{ clientToken: string }>();
const decodedClientToken = clientToken ? decodeURIComponent(clientToken) : '';
const { dateRange, setDateRange } = useOutletContext<{
dateRange: DateRange,
setDateRange: (range: DateRange) => void
}>();
const [details, setDetails] = useState<ClientDetailsAnalytics | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [currentPage, setCurrentPage] = useState(1);
const [ordersPerPage, setOrdersPerPage] = useState(5);
const [expandedOrderIds, setExpandedOrderIds] = useState<Set<string>>(() => new Set());
const { name } = useParams<{ name: string }>();
const decodedName = name ? decodeURIComponent(name) : '';
const { ordersData } = useOutletContext<{ ordersData: OrderData[] }>();
useEffect(() => {
let isMounted = true;
const { groupedOrders, totalSpent, totalItems } = useMemo(() => {
const orders = ordersData;
const clientOrders = orders.filter(order => {
const clientName = order.Nome_Cliente || `Cliente Desconhecido (Pedido ${order.Valor_Pedido})`;
return clientName === decodedName;
});
const loadClientDetails = async () => {
if (!decodedClientToken) {
if (isMounted) {
setDetails(null);
setIsLoading(false);
}
return;
const groupedOrdersMap: Record<string, { date: string, orderId: string, orderTotal: number, items: OrderData[] }> = {};
let totalSpent = 0;
let totalItems = 0;
clientOrders.forEach(order => {
totalSpent += (order.Quantidade * order.Valor_Unitario);
totalItems += order.Quantidade;
// Use ID_Pedido if available, otherwise fallback to date and total order value
const key = order.ID_Pedido || `${order.Data_Pedido}_${order.Valor_Pedido}`;
if (!groupedOrdersMap[key]) {
groupedOrdersMap[key] = {
date: order.Data_Pedido,
orderId: order.ID_Pedido || key,
orderTotal: order.Valor_Pedido,
items: []
};
}
groupedOrdersMap[key].items.push(order);
});
setIsLoading(true);
const nextDetails = await fetchClientDetailsAnalytics(decodedClientToken, dateRange);
// Sort grouped orders by date descending
const groupedOrders = Object.values(groupedOrdersMap).sort((a, b) => {
return parseOrderDate(b.date).getTime() - parseOrderDate(a.date).getTime();
});
if (isMounted) {
setDetails(nextDetails);
setIsLoading(false);
}
};
void loadClientDetails();
return () => {
isMounted = false;
};
}, [dateRange, decodedClientToken]);
return { groupedOrders, totalSpent, totalItems };
}, [decodedName, ordersData]);
const formatCurrency = (value: number) => {
return new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(value);
};
const formatNumber = (value: number) => {
return new Intl.NumberFormat('pt-BR').format(value);
};
const handleDateRangeChange = (range: DateRange) => {
setCurrentPage(1);
setExpandedOrderIds(new Set());
setDateRange(range);
};
const toggleOrder = (orderId: string) => {
setExpandedOrderIds(current => {
const next = new Set(current);
if (next.has(orderId)) {
next.delete(orderId);
} else {
next.add(orderId);
}
return next;
});
};
if (isLoading && !details) {
return <ClientDetailsSkeleton />;
}
if (!details?.hasClient) {
if (!groupedOrders.length) {
return (
<div className="text-center py-12">
<p className="text-zinc-500 dark:text-dark-muted font-medium">Cliente não encontrado.</p>
@@ -243,40 +58,14 @@ const ClientDetails = () => {
);
}
const {
chartData,
groupedOrders,
purchaseHours = [],
purchaseHourRangeLabel = 'Últimos 60 dias',
purchaseWeekdayRangeLabel = 'Todo período',
purchaseWeekdays = [],
allTimeOrderCount,
clientName,
clientPhone,
periodAverageTicket,
periodItems,
periodOrderCount,
periodSpent
} = details;
const displayName = clientName || 'Cliente';
const totalPages = Math.ceil(groupedOrders.length / ordersPerPage);
const safeCurrentPage = Math.min(currentPage, totalPages || 1);
const startIndex = (safeCurrentPage - 1) * ordersPerPage;
const paginatedOrders = groupedOrders.slice(startIndex, startIndex + ordersPerPage);
const hasWeekdayPattern = purchaseWeekdays.some(day => day.value > 0);
const hasHourPattern = purchaseHours.some(hour => hour.value > 0);
const weekdayPatternTotal = purchaseWeekdays.reduce((total, day) => total + day.value, 0);
const hourPatternTotal = purchaseHours.reduce((total, hour) => total + hour.value, 0);
const topWeekday = getTopPatternPoint(purchaseWeekdays);
const topHour = getTopPatternPoint(purchaseHours);
const isRefreshing = isLoading && Boolean(details);
const isSingleDayRange = formatDateKey(dateRange.start) === formatDateKey(dateRange.end);
return (
<div className="space-y-6">
{/* Header Area */}
<div className="flex flex-col gap-4">
<BackButton fallbackTo="/clients" />
<Link to="/clients" className="inline-flex items-center text-sm font-bold text-zinc-400 dark:text-dark-muted hover:text-zinc-900 dark:hover:text-dark-text transition-colors w-fit">
<ArrowLeft className="w-4 h-4 mr-2" />
Voltar
</Link>
<div className="flex flex-col md:flex-row md:items-end justify-between gap-6">
<div className="flex items-center gap-4">
@@ -284,277 +73,39 @@ const ClientDetails = () => {
<User className="w-8 h-8 text-brand-primary" />
</div>
<div>
<h1 className="text-2xl font-bold text-zinc-900 dark:text-dark-text">{displayName}</h1>
<div className="flex items-center gap-3 mt-1">
<p className="text-zinc-500 dark:text-dark-muted font-medium">
{formatNumber(allTimeOrderCount)} pedidos no histórico completo
</p>
{clientPhone && (
<>
<span className="text-zinc-300 dark:text-dark-border">•</span>
<span className="flex items-center gap-1.5 text-brand-primary font-bold text-sm bg-brand-primary/10 px-2 py-1 rounded-md">
<Phone className="w-3.5 h-3.5" />
{clientPhone}
</span>
</>
)}
</div>
<h1 className="text-2xl font-bold text-zinc-900 dark:text-dark-text">{decodedName}</h1>
<p className="text-zinc-500 dark:text-dark-muted font-medium">Histórico completo de compras</p>
</div>
</div>
<DateRangePicker
dateRange={dateRange}
onChange={handleDateRangeChange}
/>
</div>
</div>
<RefreshStatus isRefreshing={isRefreshing} />
<div className={isRefreshing ? 'refreshing-content space-y-6' : 'space-y-6'} aria-busy={isRefreshing}>
<div className="grid grid-cols-1 gap-4 md:grid-cols-4">
<div className="bg-dark-card p-5 rounded-2xl border border-dark-border flex items-center justify-between shadow-sm">
<div>
<p className="text-xs font-bold text-dark-muted uppercase tracking-widest mb-1">Gasto no Período</p>
<p className="text-2xl font-bold text-brand-primary">{formatCurrency(periodSpent)}</p>
</div>
<div className="p-3 bg-brand-primary/10 rounded-xl text-brand-primary">
<DollarSign size={22} />
</div>
</div>
<div className="bg-dark-card p-5 rounded-2xl border border-dark-border flex items-center justify-between shadow-sm">
<div>
<p className="text-xs font-bold text-dark-muted uppercase tracking-widest mb-1">Ticket no Período</p>
<p className="text-2xl font-bold text-dark-text">{formatCurrency(periodAverageTicket)}</p>
</div>
<div className="p-3 bg-emerald-500/10 rounded-xl text-emerald-400">
<ReceiptText size={22} />
</div>
</div>
<div className="bg-dark-card p-5 rounded-2xl border border-dark-border flex items-center justify-between shadow-sm">
<div>
<p className="text-xs font-bold text-dark-muted uppercase tracking-widest mb-1">Total Pedidos</p>
<p className="text-2xl font-bold text-dark-text">{formatNumber(periodOrderCount)}</p>
</div>
<div className="p-3 bg-blue-500/10 rounded-xl text-blue-300">
<ShoppingBag size={22} />
</div>
</div>
<div className="bg-dark-card p-5 rounded-2xl border border-dark-border flex items-center justify-between shadow-sm">
<div>
<p className="text-xs font-bold text-dark-muted uppercase tracking-widest mb-1">Itens no Período</p>
<p className="text-2xl font-bold text-dark-text">{formatNumber(periodItems)}</p>
</div>
<div className="p-3 bg-purple-500/10 rounded-xl text-purple-300">
<Package size={22} />
</div>
</div>
</div>
<div className="bg-white dark:bg-dark-card border border-zinc-200 dark:border-dark-border rounded-2xl p-6 shadow-sm">
<h3 className="text-lg font-bold mb-8 text-zinc-900 dark:text-dark-text">
Gasto por {isSingleDayRange ? 'Horário' : 'Data'}
</h3>
{chartData.length === 0 ? (
<div className="flex h-[320px] items-center justify-center text-sm font-semibold text-zinc-500 dark:text-dark-muted">
Nenhum gasto no período selecionado.
</div>
) : (
<div className="h-[320px] w-full">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={chartData} margin={{ top: 5, right: 30, left: 20, bottom: isSingleDayRange ? 24 : 80 }}>
<defs>
<linearGradient id="clientSpendGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor={CHART_DETAIL_BAR_COLOR} stopOpacity={0.38} />
<stop offset="95%" stopColor={CHART_DETAIL_BAR_COLOR} stopOpacity={0.04} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke={CHART_GRID_COLOR} vertical={false} />
<XAxis
dataKey="date"
stroke={CHART_AXIS_COLOR}
fontSize={10}
tickLine={false}
axisLine={false}
interval={isSingleDayRange ? 2 : 0}
angle={isSingleDayRange ? 0 : -45}
textAnchor={isSingleDayRange ? 'middle' : 'end'}
height={isSingleDayRange ? 24 : 80}
/>
<YAxis stroke={CHART_AXIS_COLOR} fontSize={12} tickLine={false} axisLine={false} tickFormatter={(value) => formatCurrency(Number(value))} />
<Tooltip content={<CustomTooltip />} cursor={{ fill: CHART_CURSOR_COLOR }} />
<Area
type="monotone"
dataKey="value"
stroke={CHART_DETAIL_BAR_COLOR}
strokeWidth={2.25}
fill="url(#clientSpendGradient)"
dot={{ r: 3, strokeWidth: 2, fill: 'var(--color-dark-card)', stroke: CHART_DETAIL_BAR_COLOR }}
activeDot={{ r: 5, strokeWidth: 2, fill: CHART_DETAIL_BAR_COLOR, stroke: 'var(--color-dark-card)' }}
/>
</AreaChart>
</ResponsiveContainer>
</div>
)}
</div>
<section className="space-y-4">
<div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
<div>
<h3 className="text-lg font-bold text-zinc-900 dark:text-dark-text">Padrão de Compra</h3>
<p className="mt-1 text-sm font-medium text-zinc-500 dark:text-dark-muted">Quando este cliente costuma comprar, separando histórico de data e horário confiável.</p>
</div>
</div>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
<div className="bg-white dark:bg-dark-card border border-zinc-200 dark:border-dark-border rounded-2xl p-6 shadow-sm">
<div className="mb-4 flex items-start justify-between gap-4">
<div>
<h4 className="text-sm font-bold uppercase tracking-widest text-zinc-500 dark:text-dark-muted">Compras por Dia</h4>
{hasWeekdayPattern && topWeekday && (
<p className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-1 text-xs font-semibold text-zinc-600 dark:text-dark-muted">
<span className="h-2 w-2 rounded-full" style={{ backgroundColor: WEEKDAY_BAR_COLOR }} />
<span>Dia mais forte: <span className="text-zinc-900 dark:text-dark-text">{topWeekday.label}</span></span>
<span className="text-zinc-400 dark:text-dark-border">·</span>
<span>{topWeekday.value} {topWeekday.value === 1 ? 'pedido' : 'pedidos'}</span>
<span className="text-zinc-400 dark:text-dark-border">·</span>
<span>{getPatternShare(topWeekday.value, weekdayPatternTotal)}%</span>
</p>
)}
</div>
<span className="shrink-0 rounded-full border border-zinc-200 bg-zinc-100 px-2.5 py-1 text-[10px] font-bold uppercase tracking-wide text-zinc-600 dark:border-dark-border dark:bg-white/5 dark:text-dark-muted">
{purchaseWeekdayRangeLabel}
</span>
<div className="flex gap-8">
<div className="flex flex-col items-center">
<p className="text-xs font-bold text-zinc-400 dark:text-dark-muted uppercase tracking-widest mb-1">Total Gasto</p>
<p className="text-2xl font-bold text-brand-primary">{formatCurrency(totalSpent)}</p>
</div>
{hasWeekdayPattern ? (
<div className="h-52">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={purchaseWeekdays} margin={{ top: 8, right: 10, left: -18, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke={CHART_GRID_COLOR} vertical={false} />
<XAxis dataKey="label" stroke={CHART_AXIS_COLOR} fontSize={11} tickLine={false} axisLine={false} />
<YAxis allowDecimals={false} stroke={CHART_AXIS_COLOR} fontSize={11} tickLine={false} axisLine={false} />
<Tooltip content={<PatternTooltip />} cursor={{ fill: CHART_CURSOR_COLOR }} />
<Bar dataKey="value" radius={[4, 4, 0, 0]}>
{purchaseWeekdays.map(day => (
<Cell key={`weekday-${day.label}`} fill={WEEKDAY_BAR_COLOR} fillOpacity={0.62} stroke={WEEKDAY_BAR_COLOR} strokeOpacity={0.9} strokeWidth={1.25} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
) : (
<div className="flex h-56 items-center justify-center text-sm font-semibold text-zinc-500 dark:text-dark-muted">
Sem compras no período.
</div>
)}
</div>
<div className="bg-white dark:bg-dark-card border border-zinc-200 dark:border-dark-border rounded-2xl p-6 shadow-sm">
<div className="mb-4 flex items-start justify-between gap-4">
<div>
<h4 className="text-sm font-bold uppercase tracking-widest text-zinc-500 dark:text-dark-muted">Compras por Horário</h4>
{hasHourPattern && topHour && (
<p className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-1 text-xs font-semibold text-zinc-600 dark:text-dark-muted">
<span className="h-2 w-2 rounded-full" style={{ backgroundColor: HOUR_BAR_COLOR }} />
<span>Horário mais forte: <span className="text-zinc-900 dark:text-dark-text">{topHour.label}</span></span>
<span className="text-zinc-400 dark:text-dark-border">·</span>
<span>{topHour.value} {topHour.value === 1 ? 'pedido' : 'pedidos'}</span>
<span className="text-zinc-400 dark:text-dark-border">·</span>
<span>{getPatternShare(topHour.value, hourPatternTotal)}%</span>
</p>
)}
</div>
<span className="shrink-0 rounded-full border border-zinc-200 bg-zinc-100 px-2.5 py-1 text-[10px] font-bold uppercase tracking-wide text-zinc-600 dark:border-dark-border dark:bg-white/5 dark:text-dark-muted">
{purchaseHourRangeLabel}
</span>
<div className="flex flex-col items-center">
<p className="text-xs font-bold text-zinc-400 dark:text-dark-muted uppercase tracking-widest mb-1">Itens Comprados</p>
<p className="text-2xl font-bold text-zinc-900 dark:text-dark-text">{totalItems}</p>
</div>
{hasHourPattern ? (
<div className="h-52">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={purchaseHours} margin={{ top: 8, right: 10, left: -18, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke={CHART_GRID_COLOR} vertical={false} />
<XAxis
dataKey="label"
stroke={CHART_AXIS_COLOR}
fontSize={10}
tickLine={false}
axisLine={false}
interval={0}
tickFormatter={(value) => Number(String(value).replace('h', '')) % 3 === 0 ? String(value) : ''}
/>
<YAxis allowDecimals={false} stroke={CHART_AXIS_COLOR} fontSize={11} tickLine={false} axisLine={false} />
<Tooltip content={<PatternTooltip />} cursor={{ fill: CHART_CURSOR_COLOR }} />
<Bar dataKey="value" radius={[4, 4, 0, 0]}>
{purchaseHours.map(hour => (
<Cell key={`hour-${hour.label}`} fill={HOUR_BAR_COLOR} fillOpacity={0.56} stroke={HOUR_BAR_COLOR} strokeOpacity={0.86} strokeWidth={1.1} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
) : (
<div className="flex h-56 items-center justify-center px-6 text-center text-sm font-semibold text-zinc-500 dark:text-dark-muted">
Sem horário de compra disponível para os últimos 60 dias.
</div>
)}
</div>
</div>
</section>
</div>
{/* Orders List */}
<div className="flex flex-col gap-6">
{paginatedOrders.length === 0 ? (
<div className="bg-white dark:bg-dark-card border border-zinc-200 dark:border-dark-border rounded-2xl p-8 text-center shadow-sm">
<p className="text-sm font-bold text-zinc-900 dark:text-dark-text">Nenhum pedido no período selecionado.</p>
<p className="mt-1 text-sm text-zinc-500 dark:text-dark-muted">Altere o filtro de data para ver outros pedidos deste cliente.</p>
</div>
) : paginatedOrders.map((group) => {
const isExpanded = expandedOrderIds.has(group.orderId);
return (
<div key={group.orderId} className="bg-white dark:bg-dark-card border border-zinc-200 dark:border-dark-border rounded-2xl overflow-hidden shadow-sm">
<button
type="button"
onClick={() => toggleOrder(group.orderId)}
aria-expanded={isExpanded}
className="flex w-full cursor-pointer items-center justify-between gap-4 bg-zinc-50/50 px-4 py-3 text-left transition-colors hover:bg-zinc-100/80 dark:bg-dark-header dark:hover:bg-dark-input/60"
>
<div className="flex min-w-0 items-center gap-3">
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg border border-zinc-200 bg-white text-brand-primary dark:border-dark-border dark:bg-dark-card">
<Tag className="h-4 w-4" />
</span>
<div className="min-w-0">
<h2 className="truncate text-sm font-bold uppercase tracking-wider text-zinc-700 dark:text-dark-text">
Pedido ID: {group.orderId}
</h2>
<div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs font-bold text-zinc-400 dark:text-dark-muted">
<span>{group.items.length} {group.items.length === 1 ? 'item' : 'itens'}</span>
{group.date && (
<span className="inline-flex items-center gap-1">
<Clock className="h-3 w-3" />
{group.date}
</span>
)}
</div>
{groupedOrders.map((group, groupIndex) => (
<div key={groupIndex} className="bg-white dark:bg-dark-card border border-zinc-200 dark:border-dark-border rounded-2xl overflow-hidden shadow-sm">
<div className="p-4 border-b border-zinc-100 dark:border-dark-border bg-zinc-50/50 dark:bg-dark-header flex justify-between items-center">
<h2 className="text-sm font-bold uppercase tracking-wider text-zinc-500 dark:text-dark-muted flex items-center gap-2">
<Tag className="w-4 h-4 text-brand-primary" />
Pedido ID: {group.orderId}
</h2>
<span className="text-sm font-bold text-brand-primary">
Total do Pedido: {formatCurrency(group.orderTotal)}
</span>
</div>
</div>
<div className="flex shrink-0 items-center gap-3">
<div className="text-right">
<p className="text-[10px] font-bold uppercase tracking-widest text-zinc-400 dark:text-dark-muted">Total</p>
<p className="text-sm font-bold text-brand-primary">{formatCurrency(group.orderTotal)}</p>
</div>
<span className="flex h-8 w-8 items-center justify-center rounded-lg border border-zinc-200 bg-white text-zinc-500 transition-colors dark:border-dark-border dark:bg-dark-card dark:text-dark-muted">
<ChevronDown className={`h-4 w-4 transition-transform ${isExpanded ? 'rotate-180' : ''}`} />
</span>
</div>
</button>
{isExpanded && (
<div className="divide-y divide-zinc-100 dark:divide-dark-border">
{group.items.map((order, index) => {
const metadata = getOrderMetadata(order);
return (
<div className="divide-y divide-zinc-100 dark:divide-dark-border">
{group.items.map((order, index) => (
<div key={`${order.ID_Produto}-${index}`} className="px-4 py-2 flex flex-col md:flex-row md:items-center justify-between gap-3 hover:bg-zinc-50/50 dark:hover:bg-dark-input/30 transition-colors">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-0.5">
@@ -583,15 +134,6 @@ const ClientDetails = () => {
<span className="text-zinc-500 dark:text-dark-muted font-medium">Preço: <span className="text-zinc-900 dark:text-dark-text font-bold">{formatCurrency(order.Valor_Unitario)}</span></span>
</div>
</div>
{metadata.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1.5">
{metadata.map(value => (
<span key={value} className="max-w-full break-all rounded-md border border-zinc-200 dark:border-dark-border px-2 py-0.5 text-[10px] font-bold text-zinc-500 dark:text-dark-muted">
{value}
</span>
))}
</div>
)}
</div>
<div className="text-right shrink-0">
@@ -599,32 +141,10 @@ const ClientDetails = () => {
<p className="text-base font-bold text-zinc-900 dark:text-dark-text">{formatCurrency(order.Quantidade * order.Valor_Unitario)}</p>
</div>
</div>
);
})}
))}
</div>
</div>
)}
</div>
);
})}
</div>
<PaginationControls
totalItems={groupedOrders.length}
currentPage={safeCurrentPage}
totalPages={totalPages}
pageSize={ordersPerPage}
pageSizeOptions={[5, 10, 20, 50]}
itemLabel="pedidos"
pageSizeLabel="pedidos por página"
startIndex={startIndex}
endIndex={Math.min(startIndex + ordersPerPage, groupedOrders.length)}
onPageChange={setCurrentPage}
onPageSizeChange={(pageSize) => {
setOrdersPerPage(pageSize);
setCurrentPage(1);
}}
className="px-6 py-4 border border-zinc-200 dark:border-dark-border rounded-2xl bg-white dark:bg-dark-card"
/>
))}
</div>
</div>
);

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,536 +1,121 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useMemo } from 'react';
import { useOutletContext, useNavigate } from 'react-router-dom';
import { AreaChart, Area, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, PieChart, Pie, Cell, Line } from 'recharts';
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, PieChart, Pie, Cell } from 'recharts';
import { DollarSign, ShoppingCart, TrendingUp } from 'lucide-react';
import DateRangePicker from '../components/DateRangePicker';
import RefreshStatus from '../components/RefreshStatus';
import type { DashboardAnalytics, OrderData, DateRange } from '../types';
import { applyDashboardColors, buildDashboardMetrics } from '../analytics/dashboard';
import { fetchDashboardAnalytics } from '../dataService';
import { averageRecentValues, formatDateBucketLabel, formatDateBucketLongLabel, getAutoDateBucket, getDateBucketKey, getMovingAverageWindow, type DateBucket } from '../chartUtils';
import type { OrderData, DateRange } from '../types';
import { parseOrderDate } from '../dataService';
const COLORS = [
// 10 Strong Base Colors
'#10b981', '#3b82f6', '#8b5cf6', '#f43f5e', '#f97316',
'#06b6d4', '#ec4899', '#eab308', '#6366f1', '#14b8a6',
// 10 Softer Versions
'#6ee7b7', '#93c5fd', '#c4b5fd', '#fda4af', '#fdba74',
'#67e8f9', '#f9a8d4', '#fde047', '#a5b4fc', '#5eead4'
];
const globalColorMap: Record<string, string> = {};
let globalColorIndex = 0;
const getProductColor = (name: string) => {
if (!globalColorMap[name]) {
globalColorMap[name] = COLORS[globalColorIndex % COLORS.length];
globalColorIndex++;
}
return globalColorMap[name];
};
const formatCurrency = (value: number) => {
return new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(value);
};
const formatCompactCurrency = (value: number) => {
const absValue = Math.abs(value);
const formatNumber = (nextValue: number) => Number.isInteger(nextValue)
? String(nextValue)
: nextValue.toLocaleString('pt-BR', { maximumFractionDigits: 1 });
if (absValue >= 1_000_000) return `${formatNumber(value / 1_000_000)}M`;
if (absValue >= 1_000) return `${formatNumber(value / 1_000)}k`;
return formatNumber(value);
};
const formatHourKey = (hour: number) => `${String(hour).padStart(2, '0')}h`;
const formatDateKey = (date: Date) => {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
};
const SELLER_COLORS = [
'#25C2FF',
'#52DFA0',
'#A06BFF',
'#FF8A63',
'#FFC247',
'#FF7A9B',
'#18D6B5',
'#FFA24A',
'#82B8FF',
'#B8E84D'
];
const BAR_FILL_OPACITY = 0.5;
const BAR_STROKE_OPACITY = 0.85;
const PIE_FILL_OPACITY = 0.68;
const CHART_GRID_COLOR = 'var(--chart-grid)';
const CHART_AXIS_COLOR = 'var(--chart-axis)';
const CHART_CURSOR_COLOR = 'var(--chart-cursor)';
type SellerMetricKey = 'revenue' | 'ticket' | 'orders';
type SellerTimeSeriesSeller = {
id: string;
seriesKey: string;
name: string;
fill: string;
total: number;
revenue: number;
orders: number;
};
type SellerTimeSeriesChartData = {
date: string;
movingAverage?: number;
[key: string]: string | number | undefined;
};
type SellerMetricConfig = {
key: SellerMetricKey;
label: string;
title: string;
fallbackDescription: string;
trendDescription: string;
emptyText: string;
formatValue: (value: number) => string;
formatTick: (value: number) => string;
};
const sellerMetricOptions: SellerMetricConfig[] = [
{
key: 'revenue',
label: 'Receita',
title: 'Receita por Vendedor',
fallbackDescription: 'Total por vendedor no período selecionado.',
trendDescription: 'Evolução por data no período selecionado.',
emptyText: 'Nenhuma receita com vendedor no período.',
formatValue: formatCurrency,
formatTick: formatCompactCurrency
},
{
key: 'ticket',
label: 'Ticket médio',
title: 'Ticket Médio por Vendedor',
fallbackDescription: 'Ticket médio por vendedor no período selecionado.',
trendDescription: 'Ticket médio por data no período selecionado.',
emptyText: 'Nenhum ticket médio com vendedor no período.',
formatValue: formatCurrency,
formatTick: formatCompactCurrency
},
{
key: 'orders',
label: 'Pedidos',
title: 'Pedidos por Vendedor',
fallbackDescription: 'Pedidos por vendedor no período selecionado.',
trendDescription: 'Pedidos por data no período selecionado.',
emptyText: 'Nenhum pedido com vendedor no período.',
formatValue: (value) => new Intl.NumberFormat('pt-BR').format(value),
formatTick: (value) => new Intl.NumberFormat('pt-BR', { maximumFractionDigits: 0 }).format(value)
}
];
const getSellerMetricValue = (metric: SellerMetricKey, revenue: number, orders: number) => {
if (metric === 'orders') return orders;
if (metric === 'ticket') return orders ? revenue / orders : 0;
return revenue;
};
const normalizeSellerGraphName = (name: string) => (
name
.normalize('NFD')
.replace(/\p{Diacritic}/gu, '')
.trim()
.toLowerCase()
);
const shouldHideSellerFromGraph = (name: string) => {
const normalizedName = normalizeSellerGraphName(name);
return normalizedName === 'pos vendas';
};
const getBarGlowStyle = (color: string) => ({
filter: `drop-shadow(0 0 3px ${color}40)`
});
const getPieGlowStyle = (color: string) => ({
filter: `drop-shadow(0 0 8px ${color}80)`,
cursor: 'pointer'
});
const kpiIconStyle = (color: string) => ({
backgroundColor: `${color}14`,
borderColor: `${color}26`,
color
});
const DashboardSkeleton = () => (
<div className="space-y-6" aria-label="Carregando dashboard">
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{[0, 1, 2].map(item => (
<div key={`dashboard-kpi-skeleton-${item}`} className="bg-dark-card p-6 rounded-2xl border border-dark-border shadow-sm">
<div className="flex justify-between gap-6">
<div className="w-full">
<div className="skeleton h-4 w-32" />
<div className="skeleton mt-3 h-8 w-44" />
</div>
<div className="skeleton h-12 w-12 shrink-0 rounded-xl" />
</div>
</div>
))}
</div>
<div className="bg-dark-card p-6 rounded-2xl border border-dark-border shadow-sm">
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div className="skeleton h-5 w-48" />
</div>
<div className="mt-8">
<div className="skeleton h-80 w-full" />
<div className="mt-5 flex flex-wrap gap-2">
{[0, 1, 2, 3, 4, 5].map(row => (
<div key={`dashboard-seller-legend-skeleton-${row}`} className="skeleton h-9 rounded-full" style={{ width: `${150 - row * 7}px` }} />
))}
</div>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{[0, 1].map(item => (
<div key={`dashboard-product-chart-skeleton-${item}`} className="bg-dark-card p-6 rounded-2xl border border-dark-border shadow-sm">
<div className="skeleton h-5 w-44" />
<div className="mt-8 h-80">
<div className="flex h-full items-end justify-center gap-3">
{[0, 1, 2, 3, 4, 5, 6, 7].map(bar => (
<div key={`dashboard-column-skeleton-${item}-${bar}`} className="skeleton w-10" style={{ height: `${35 + ((bar * 17) % 55)}%` }} />
))}
</div>
</div>
</div>
))}
</div>
</div>
);
type ChartTooltipPayload = {
value: number;
name?: string;
color?: string;
stroke?: string;
dataKey?: string;
payload?: {
fill?: string;
};
};
type CustomTooltipProps = {
active?: boolean;
payload?: ChartTooltipPayload[];
label?: string;
isCurrency?: boolean;
valueLabel?: string;
};
const CustomTooltip = ({ active, payload, label, isCurrency, valueLabel }: CustomTooltipProps) => {
const CustomTooltip = ({ active, payload, label, isCurrency }: any) => {
if (active && payload && payload.length) {
const color = payload[0].payload?.fill || payload[0].stroke || payload[0].color || 'var(--chart-detail-bar)';
const color = payload[0].payload?.fill || payload[0].color || '#9ECAE1';
const displayLabel = label || payload[0].name;
const value = isCurrency ? formatCurrency(payload[0].value) : payload[0].value;
const displayValueLabel = valueLabel || (isCurrency ? 'Receita:' : 'Vendas:');
const valueLabel = isCurrency ? 'Receita:' : 'Vendas:';
return (
<div
className="rounded-xl border p-3 shadow-lg"
style={{ backgroundColor: 'var(--chart-tooltip-bg)', borderColor: 'var(--chart-tooltip-border)' }}
>
<div className="bg-[#141414] p-3 rounded-xl shadow-lg border-none">
<p className="font-bold mb-1" style={{ color }}>{displayLabel}</p>
<p className="m-0" style={{ color: 'var(--chart-tooltip-text)' }}>{displayValueLabel} {value}</p>
<p className="text-[#ededed] m-0">{valueLabel} {value}</p>
</div>
);
}
return null;
};
type SellerMetricTooltipProps = {
active?: boolean;
payload?: ChartTooltipPayload[];
label?: string;
focusedSellerId: string | null;
sellers: SellerTimeSeriesSeller[];
metricConfig: SellerMetricConfig;
isHourly: boolean;
dateBucket: DateBucket;
};
const SellerMetricTooltip = ({ active, payload, label, focusedSellerId, sellers, metricConfig, isHourly, dateBucket }: SellerMetricTooltipProps) => {
if (!active || !payload?.length) return null;
const sellersByKey = new Map(sellers.map(seller => [seller.seriesKey, seller]));
const rows = payload
.map(item => {
const seller = sellersByKey.get(String(item.dataKey || ''));
return seller ? { seller, value: Number(item.value || 0) } : null;
})
.filter((item): item is { seller: SellerTimeSeriesSeller; value: number } => Boolean(item && item.value > 0))
.sort((a, b) => {
if (focusedSellerId) {
if (a.seller.id === focusedSellerId) return -1;
if (b.seller.id === focusedSellerId) return 1;
}
return b.value - a.value;
})
.slice(0, focusedSellerId ? 4 : 6);
return (
<div
className="min-w-56 rounded-xl border p-3 shadow-lg"
style={{ backgroundColor: 'var(--chart-tooltip-bg)', borderColor: 'var(--chart-tooltip-border)' }}
>
<p className="mb-2 text-xs font-bold uppercase tracking-wide text-dark-muted">
{isHourly ? String(label || '') : formatDateBucketLongLabel(String(label || ''), dateBucket)}
</p>
<div className="space-y-2 text-sm" style={{ color: 'var(--chart-tooltip-text)' }}>
{rows.length ? rows.map(({ seller, value }) => (
<div key={`seller-tooltip-${seller.id}`} className="flex items-center justify-between gap-4">
<span className="flex min-w-0 items-center gap-2 font-semibold">
<span className="h-2.5 w-2.5 shrink-0 rounded-full" style={{ backgroundColor: seller.fill }} />
<span className="truncate">{seller.name}</span>
</span>
<span className="shrink-0 font-bold">{metricConfig.formatValue(value)}</span>
</div>
)) : (
<p className="m-0 text-dark-muted">Sem receita nesse dia.</p>
)}
</div>
</div>
);
};
const Dashboard = () => {
const navigate = useNavigate();
const { dateRange, setDateRange, ordersData, refreshInterval, setRefreshInterval } = useOutletContext<{
const { dateRange, setDateRange, ordersData, refreshInterval, setRefreshInterval, loadData } = useOutletContext<{
dateRange: DateRange,
setDateRange: (range: DateRange) => void,
ordersData: OrderData[],
refreshInterval: number,
setRefreshInterval: (interval: number) => void,
loadData: (showLoading?: boolean) => void
}>();
const [serverMetrics, setServerMetrics] = useState<DashboardAnalytics | null>(null);
const [isMetricsLoading, setIsMetricsLoading] = useState(true);
const [focusedSellerId, setFocusedSellerId] = useState<string | null>(null);
const [sellerMetric, setSellerMetric] = useState<SellerMetricKey>('revenue');
const [selectedSellerBucket, setSelectedSellerBucket] = useState<string | null>(null);
const [showSellerTrend, setShowSellerTrend] = useState(false);
const loadDashboardMetrics = useCallback(async (range: DateRange, options?: { force?: boolean }) => {
setIsMetricsLoading(true);
const metrics = await fetchDashboardAnalytics(range, options);
setServerMetrics(metrics);
setIsMetricsLoading(false);
}, []);
const filteredData = useMemo(() => {
const orders = ordersData;
return orders.filter(order => {
const orderDate = parseOrderDate(order.Data_Pedido);
return orderDate >= dateRange.start && orderDate <= dateRange.end;
});
}, [dateRange, ordersData]);
useEffect(() => {
// Dashboard metrics are synchronized with the selected server-side date range.
// eslint-disable-next-line react-hooks/set-state-in-effect
void loadDashboardMetrics(dateRange);
}, [dateRange, loadDashboardMetrics]);
const { totalRevenue, totalOrders, averageOrderValue, salesByProduct, revenueByProduct } = useMemo(() => {
let revenue = 0;
let totalItems = 0;
const productSalesMap: Record<string, number> = {};
const productRevenueMap: Record<string, number> = {};
const productNameIdMap: Record<string, string> = {};
useEffect(() => {
if (refreshInterval === 0) return;
filteredData.forEach(order => {
const itemRevenue = order.Quantidade * order.Valor_Unitario;
revenue += itemRevenue;
totalItems += order.Quantidade;
const productName = order.Descricao_Produto.split(' TAMANHO')[0];
productNameIdMap[productName] = order.ID_Produto;
const intervalId = setInterval(() => {
void loadDashboardMetrics(dateRange, { force: true });
}, refreshInterval);
return () => clearInterval(intervalId);
}, [dateRange, loadDashboardMetrics, refreshInterval]);
const { totalRevenue, totalOrders, averageOrderValue, salesByProduct, revenueByProduct, revenueBySeller, ordersBySeller, sellerRevenueByDate, sellerRevenueByHour } = useMemo(() => {
if (serverMetrics) return applyDashboardColors(serverMetrics);
return buildDashboardMetrics(ordersData, dateRange);
}, [dateRange, ordersData, serverMetrics]);
const chartRevenueBySeller = useMemo(
() => revenueBySeller.filter(seller => !shouldHideSellerFromGraph(seller.name)),
[revenueBySeller]
);
const chartOrdersBySeller = useMemo(
() => ordersBySeller.filter(seller => !shouldHideSellerFromGraph(seller.name)),
[ordersBySeller]
);
const chartSellerRevenueByDate = useMemo(
() => sellerRevenueByDate.filter(seller => !shouldHideSellerFromGraph(seller.name)),
[sellerRevenueByDate]
);
const chartSellerRevenueByHour = useMemo(
() => sellerRevenueByHour.filter(seller => !shouldHideSellerFromGraph(seller.name)),
[sellerRevenueByHour]
);
const sellerColorMap = useMemo(() => {
const colorMap = new Map<string, string>();
[...chartRevenueBySeller, ...chartOrdersBySeller].forEach((seller) => {
const key = seller.id || seller.name;
if (!colorMap.has(key)) {
colorMap.set(key, SELLER_COLORS[colorMap.size % SELLER_COLORS.length]);
if (productSalesMap[productName]) {
productSalesMap[productName] += order.Quantidade;
productRevenueMap[productName] += itemRevenue;
} else {
productSalesMap[productName] = order.Quantidade;
productRevenueMap[productName] = itemRevenue;
}
});
return colorMap;
}, [chartOrdersBySeller, chartRevenueBySeller]);
// Identify which products will actually be displayed in both charts (Top 10 of each)
const topSalesNames = Object.keys(productSalesMap).sort((a, b) => productSalesMap[b] - productSalesMap[a]).slice(0, 10);
const topRevenueNames = Object.keys(productRevenueMap).sort((a, b) => productRevenueMap[b] - productRevenueMap[a]).slice(0, 10);
const sellerMetricConfig = sellerMetricOptions.find(option => option.key === sellerMetric) || sellerMetricOptions[0];
// Combine them into a unique set to assign colors only to the VISIBLE products
const displayProducts = Array.from(new Set([...topSalesNames, ...topRevenueNames])).sort();
const productColors: Record<string, string> = {};
const sellerTimeSeries = useMemo(() => {
const isSingleDayRange = formatDateKey(dateRange.start) === formatDateKey(dateRange.end);
const isHourly = isSingleDayRange && chartSellerRevenueByHour.length > 0;
const dateBucket = getAutoDateBucket(dateRange);
const activeTrendPoints = isHourly
? chartSellerRevenueByHour.map(point => ({
...point,
date: formatHourKey(point.hour)
}))
: chartSellerRevenueByDate;
if (!activeTrendPoints.length && chartRevenueBySeller.length) {
const startDate = formatDateKey(dateRange.start);
const endDate = formatDateKey(dateRange.end);
const chartDates = isSingleDayRange && isHourly
? Array.from({ length: 24 }, (_, hour) => formatHourKey(hour))
: startDate === endDate ? [endDate] : [startDate, endDate];
const sellersById = new Map<string, Omit<SellerTimeSeriesSeller, 'seriesKey' | 'total'>>();
chartRevenueBySeller.forEach(seller => {
const id = seller.id || seller.name;
sellersById.set(id, {
id,
name: seller.name,
fill: sellerColorMap.get(id) || seller.fill,
revenue: seller.value,
orders: 0
});
});
chartOrdersBySeller.forEach(seller => {
const id = seller.id || seller.name;
const existing = sellersById.get(id);
sellersById.set(id, {
id,
name: existing?.name || seller.name,
fill: existing?.fill || sellerColorMap.get(id) || seller.fill,
revenue: existing?.revenue || 0,
orders: seller.value
});
});
const sellers: SellerTimeSeriesSeller[] = [...sellersById.values()]
.map(seller => ({
...seller,
total: getSellerMetricValue(sellerMetric, seller.revenue, seller.orders),
seriesKey: ''
}))
.filter(seller => seller.total > 0)
.sort((a, b) => b.total - a.total)
.slice(0, 8)
.map((seller, index) => {
const id = seller.id || seller.name;
return {
...seller,
id,
seriesKey: `seller_${index}`
};
});
const chartData: SellerTimeSeriesChartData[] = chartDates.map(date => {
const row: SellerTimeSeriesChartData = { date };
sellers.forEach(seller => {
row[seller.seriesKey] = seller.total;
});
return row;
});
return { sellers, chartData, isFallback: true, isHourly, dateBucket, movingAverageWindow: 0 };
}
const sellersById = new Map<string, Omit<SellerTimeSeriesSeller, 'seriesKey' | 'total'>>();
const valuesByDate = new Map<string, Map<string, { revenue: number; orders: number }>>();
activeTrendPoints.forEach(point => {
const id = point.id || point.name;
const bucketKey = isHourly ? point.date : getDateBucketKey(point.date, dateBucket);
const existing = sellersById.get(id);
sellersById.set(id, {
id,
name: point.name,
fill: sellerColorMap.get(id) || point.fill,
revenue: (existing?.revenue || 0) + point.value,
orders: (existing?.orders || 0) + (point.orders || 0)
});
if (!valuesByDate.has(bucketKey)) {
valuesByDate.set(bucketKey, new Map());
}
const dateValues = valuesByDate.get(bucketKey);
if (dateValues) {
const existingValue = dateValues.get(id);
dateValues.set(id, {
revenue: (existingValue?.revenue || 0) + point.value,
orders: (existingValue?.orders || 0) + (point.orders || 0)
});
}
displayProducts.forEach((name) => {
productColors[name] = getProductColor(name);
});
const sellers: SellerTimeSeriesSeller[] = [...sellersById.values()]
.map(seller => ({
...seller,
total: getSellerMetricValue(sellerMetric, seller.revenue, seller.orders),
seriesKey: ''
}))
.filter(seller => seller.total > 0)
.sort((a, b) => b.total - a.total)
.slice(0, 8)
.map((seller, index) => ({
...seller,
seriesKey: `seller_${index}`
}));
const productsData = topSalesNames.map(name => ({
name,
id: productNameIdMap[name],
value: productSalesMap[name],
fill: productColors[name]
}));
const chartKeys = isHourly
? Array.from({ length: 24 }, (_, hour) => formatHourKey(hour))
: [...valuesByDate.keys()].sort((dateA, dateB) => dateA.localeCompare(dateB));
const revenueData = topRevenueNames.map(name => ({
name,
id: productNameIdMap[name],
value: productRevenueMap[name],
fill: productColors[name]
}));
const chartData: SellerTimeSeriesChartData[] = chartKeys
.map(date => {
const values = valuesByDate.get(date);
const row: SellerTimeSeriesChartData = { date };
sellers.forEach(seller => {
const value = values?.get(seller.id);
row[seller.seriesKey] = value ? getSellerMetricValue(sellerMetric, value.revenue, value.orders) : 0;
});
return row;
});
const movingAverageWindow = isHourly ? 0 : getMovingAverageWindow(dateBucket);
const totals = chartData.map(row => (
sellers.reduce((total, seller) => total + Number(row[seller.seriesKey] || 0), 0)
));
if (movingAverageWindow > 1) {
chartData.forEach((row, index) => {
row.movingAverage = averageRecentValues(totals, index, movingAverageWindow);
});
}
return { sellers, chartData, isFallback: false, isHourly, dateBucket, movingAverageWindow };
}, [chartOrdersBySeller, chartRevenueBySeller, chartSellerRevenueByDate, chartSellerRevenueByHour, dateRange, sellerColorMap, sellerMetric]);
const focusedSeller = focusedSellerId ? sellerTimeSeries.sellers.find(seller => seller.id === focusedSellerId) : null;
const effectiveFocusedSellerId = focusedSeller?.id || null;
const selectedSellerRow = selectedSellerBucket
? sellerTimeSeries.chartData.find(row => row.date === selectedSellerBucket)
: null;
const selectedSellerBreakdown = selectedSellerRow
? sellerTimeSeries.sellers
.map(seller => ({
seller,
value: Number(selectedSellerRow[seller.seriesKey] || 0)
}))
.filter(item => item.value > 0)
.sort((a, b) => b.value - a.value)
: [];
const selectedSellerBucketLabel = selectedSellerRow
? sellerTimeSeries.isHourly
? selectedSellerRow.date
: formatDateBucketLongLabel(selectedSellerRow.date, sellerTimeSeries.dateBucket)
: '';
const handleManualRefresh = () => {
void loadDashboardMetrics(dateRange, { force: true });
};
const shouldShowSkeleton = isMetricsLoading && !serverMetrics;
const isRefreshing = isMetricsLoading && Boolean(serverMetrics);
return { totalRevenue: revenue, totalOrders: totalItems, averageOrderValue: revenue / (filteredData.length || 1), salesByProduct: productsData, revenueByProduct: revenueData };
}, [filteredData]);
return (
<div className="space-y-6">
@@ -544,16 +129,10 @@ const Dashboard = () => {
onChange={setDateRange}
refreshInterval={refreshInterval}
setRefreshInterval={setRefreshInterval}
onManualRefresh={handleManualRefresh}
onManualRefresh={() => loadData(true)}
/>
</div>
<RefreshStatus isRefreshing={isRefreshing} />
{shouldShowSkeleton ? (
<DashboardSkeleton />
) : (
<div className={isRefreshing ? 'refreshing-content space-y-6' : 'space-y-6'} aria-busy={isRefreshing}>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="bg-dark-card p-6 rounded-2xl border border-dark-border shadow-sm">
<div className="flex justify-between items-start">
@@ -561,8 +140,8 @@ const Dashboard = () => {
<p className="text-dark-muted text-sm font-medium mb-1">Receita Total</p>
<h3 className="text-3xl font-bold text-dark-text">{formatCurrency(totalRevenue)}</h3>
</div>
<div className="rounded-xl border p-3" style={kpiIconStyle('#52DFA0')}>
<DollarSign className="w-6 h-6" />
<div className="p-3 bg-emerald-500/10 rounded-xl">
<DollarSign className="w-6 h-6 text-emerald-500" />
</div>
</div>
</div>
@@ -573,8 +152,8 @@ const Dashboard = () => {
<p className="text-dark-muted text-sm font-medium mb-1">Total de Produtos Vendidos</p>
<h3 className="text-3xl font-bold text-dark-text">{totalOrders}</h3>
</div>
<div className="rounded-xl border p-3" style={kpiIconStyle('#82B8FF')}>
<ShoppingCart className="w-6 h-6" />
<div className="p-3 bg-blue-500/10 rounded-xl">
<ShoppingCart className="w-6 h-6 text-blue-500" />
</div>
</div>
</div>
@@ -585,278 +164,29 @@ const Dashboard = () => {
<p className="text-dark-muted text-sm font-medium mb-1">Ticket Médio (Por Item)</p>
<h3 className="text-3xl font-bold text-dark-text">{formatCurrency(averageOrderValue)}</h3>
</div>
<div className="rounded-xl border p-3" style={kpiIconStyle('#B992FF')}>
<TrendingUp className="w-6 h-6" />
<div className="p-3 bg-purple-500/10 rounded-xl">
<TrendingUp className="w-6 h-6 text-purple-500" />
</div>
</div>
</div>
</div>
<div className="bg-dark-card p-6 rounded-2xl border border-dark-border shadow-sm flex flex-col">
<div className="mb-6 flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
<div>
<h3 className="text-lg font-bold text-dark-text">{sellerMetricConfig.title}</h3>
<p className="mt-1 text-sm font-medium text-dark-muted">
{focusedSeller
? `Foco em ${focusedSeller.name}`
: sellerTimeSeries.isHourly
? 'Evolução por horário no dia selecionado.'
: sellerTimeSeries.isFallback
? sellerMetricConfig.fallbackDescription
: sellerTimeSeries.dateBucket === 'day'
? sellerMetricConfig.trendDescription
: sellerTimeSeries.dateBucket === 'week'
? 'Evolução agrupada por semana no período selecionado.'
: 'Evolução agrupada por mês no período selecionado.'}
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
{!sellerTimeSeries.isHourly && !sellerTimeSeries.isFallback && sellerTimeSeries.movingAverageWindow > 1 && (
<button
type="button"
onClick={() => setShowSellerTrend(current => !current)}
aria-pressed={showSellerTrend}
className={`h-9 cursor-pointer rounded-lg border px-3 text-xs font-bold transition-colors ${
showSellerTrend
? 'border-brand-primary bg-brand-primary/10 text-brand-primary'
: 'border-dark-border bg-dark-input text-dark-muted hover:text-dark-text'
}`}
title="Mostrar ou esconder média móvel"
>
Tendência
</button>
)}
<div className="inline-flex rounded-xl border border-dark-border bg-dark-input p-1">
{sellerMetricOptions.map(option => (
<button
key={option.key}
type="button"
onClick={() => setSellerMetric(option.key)}
className={`h-8 cursor-pointer rounded-lg px-3 text-xs font-bold transition-colors ${
sellerMetric === option.key
? 'bg-dark-card text-dark-text shadow-sm'
: 'text-dark-muted hover:text-dark-text'
}`}
>
{option.label}
</button>
))}
</div>
{focusedSeller && (
<button
type="button"
onClick={() => setFocusedSellerId(null)}
className="h-9 rounded-lg border border-dark-border bg-dark-input px-3 text-xs font-bold text-dark-muted transition-colors hover:text-dark-text"
>
Ver todos
</button>
)}
</div>
</div>
{sellerTimeSeries.sellers.length ? (
<div>
<div className="h-96 min-w-0">
<ResponsiveContainer width="100%" height="100%">
<AreaChart
data={sellerTimeSeries.chartData}
margin={{ top: 14, right: 24, left: 4, bottom: 12 }}
onClick={(event) => {
if (event?.activeLabel) setSelectedSellerBucket(String(event.activeLabel));
}}
>
<defs>
{sellerTimeSeries.sellers.map(seller => (
<linearGradient key={`seller-gradient-${seller.seriesKey}`} id={`seller-gradient-${seller.seriesKey}`} x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor={seller.fill} stopOpacity={effectiveFocusedSellerId && effectiveFocusedSellerId !== seller.id ? 0.08 : 0.42} />
<stop offset="95%" stopColor={seller.fill} stopOpacity={effectiveFocusedSellerId && effectiveFocusedSellerId !== seller.id ? 0.01 : 0.05} />
</linearGradient>
))}
</defs>
<CartesianGrid strokeDasharray="3 3" stroke={CHART_GRID_COLOR} vertical={false} />
<XAxis
dataKey="date"
stroke={CHART_AXIS_COLOR}
fontSize={11}
tickLine={false}
axisLine={false}
minTickGap={sellerTimeSeries.isHourly ? 10 : 18}
interval={sellerTimeSeries.isHourly ? 2 : undefined}
tickFormatter={(value) => (
sellerTimeSeries.isHourly
? String(value)
: formatDateBucketLabel(String(value), sellerTimeSeries.dateBucket)
)}
/>
<YAxis
stroke={CHART_AXIS_COLOR}
fontSize={11}
tickLine={false}
axisLine={false}
tickFormatter={(value) => sellerMetricConfig.formatTick(Number(value))}
width={54}
/>
<Tooltip
content={<SellerMetricTooltip focusedSellerId={effectiveFocusedSellerId} sellers={sellerTimeSeries.sellers} metricConfig={sellerMetricConfig} isHourly={sellerTimeSeries.isHourly} dateBucket={sellerTimeSeries.dateBucket} />}
cursor={{ stroke: CHART_AXIS_COLOR, strokeDasharray: '4 4' }}
/>
{showSellerTrend && !sellerTimeSeries.isHourly && !sellerTimeSeries.isFallback && sellerTimeSeries.movingAverageWindow > 1 && (
<Line
type="monotone"
dataKey="movingAverage"
name="Média móvel"
stroke="var(--chart-label)"
strokeWidth={2.5}
strokeDasharray="6 5"
dot={false}
activeDot={false}
isAnimationActive
animationBegin={160}
animationDuration={700}
/>
)}
{sellerTimeSeries.sellers.map(seller => {
const isFocused = effectiveFocusedSellerId === seller.id;
const isDimmed = Boolean(effectiveFocusedSellerId && !isFocused);
return (
<Area
key={seller.id}
type="monotone"
dataKey={seller.seriesKey}
name={seller.name}
stroke={seller.fill}
strokeWidth={isFocused ? 3 : 2}
strokeOpacity={isDimmed ? 0.2 : 0.95}
fill={`url(#seller-gradient-${seller.seriesKey})`}
fillOpacity={isDimmed ? 0.25 : 1}
dot={sellerTimeSeries.isFallback ? {
r: 3,
strokeWidth: 2,
fill: seller.fill,
stroke: 'var(--color-dark-card)'
} : false}
activeDot={{
r: isFocused ? 5.5 : 4,
strokeWidth: 2,
fill: seller.fill,
stroke: 'var(--color-dark-card)',
onClick: () => setFocusedSellerId(current => current === seller.id ? null : seller.id)
}}
onClick={() => setFocusedSellerId(current => current === seller.id ? null : seller.id)}
style={{ cursor: 'pointer' }}
isAnimationActive
animationBegin={120}
animationDuration={700}
animationEasing="ease-out"
/>
);
})}
</AreaChart>
</ResponsiveContainer>
</div>
{selectedSellerRow && (
<div className="mt-4 rounded-xl border border-dark-border bg-dark-input/45 p-4">
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
<div>
<h4 className="text-sm font-bold text-dark-text">{selectedSellerBucketLabel}</h4>
<p className="mt-1 text-xs font-semibold text-dark-muted">
Quebra por vendedor no ponto selecionado.
</p>
</div>
<button
type="button"
onClick={() => setSelectedSellerBucket(null)}
className="h-8 rounded-lg border border-dark-border bg-dark-card px-3 text-xs font-bold text-dark-muted transition-colors hover:text-dark-text"
>
Limpar
</button>
</div>
{selectedSellerBreakdown.length ? (
<div className="mt-4 grid gap-2 md:grid-cols-2 xl:grid-cols-3">
{selectedSellerBreakdown.slice(0, 6).map(({ seller, value }) => (
<button
key={`seller-drill-${seller.id}`}
type="button"
onClick={() => setFocusedSellerId(current => current === seller.id ? null : seller.id)}
className="flex min-w-0 cursor-pointer items-center justify-between gap-3 rounded-lg border border-dark-border bg-dark-card px-3 py-2 text-left transition-colors hover:border-brand-primary"
>
<span className="flex min-w-0 items-center gap-2">
<span className="h-2.5 w-2.5 shrink-0 rounded-full" style={{ backgroundColor: seller.fill }} />
<span className="truncate text-xs font-bold text-dark-text">{seller.name}</span>
</span>
<span className="shrink-0 text-xs font-bold text-dark-muted">{sellerMetricConfig.formatTick(value)}</span>
</button>
))}
</div>
) : (
<p className="mt-4 text-sm font-semibold text-dark-muted">Sem valores nesse ponto.</p>
)}
</div>
)}
<div className="relative mt-5 border-t border-dark-border pt-3">
<div className="flex h-8 items-center gap-5 overflow-x-auto overflow-y-hidden whitespace-nowrap pr-8 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
{sellerTimeSeries.sellers.map(seller => {
const isFocused = effectiveFocusedSellerId === seller.id;
const isDimmed = Boolean(effectiveFocusedSellerId && !isFocused);
return (
<button
key={`seller-legend-${seller.id}`}
type="button"
onClick={() => setFocusedSellerId(current => current === seller.id ? null : seller.id)}
className={`group inline-flex h-7 shrink-0 cursor-pointer items-center gap-2 rounded-md px-1.5 text-left transition-colors ${
isFocused
? 'bg-dark-input/70 text-dark-text'
: 'text-dark-muted hover:text-dark-text'
}`}
style={{ opacity: isDimmed ? 0.38 : 1 }}
title={seller.name}
>
<span className="h-1.5 w-5 shrink-0 rounded-full" style={{ backgroundColor: seller.fill }} />
<span className="max-w-[190px] truncate text-xs font-bold">{seller.name}</span>
<span className="shrink-0 text-[11px] font-bold text-dark-muted group-hover:text-dark-text">
{sellerMetricConfig.formatTick(seller.total)}
</span>
</button>
);
})}
</div>
<div className="pointer-events-none absolute bottom-0 right-0 top-3 w-10 bg-gradient-to-l from-dark-card to-transparent" />
</div>
</div>
) : (
<div className="flex h-80 items-center justify-center">
<p className="text-sm font-semibold text-dark-muted">{sellerMetricConfig.emptyText}</p>
</div>
)}
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div className="bg-dark-card p-6 rounded-2xl border border-dark-border shadow-sm flex flex-col">
<h3 className="text-lg font-bold mb-6 text-dark-text">Produtos Mais Vendidos</h3>
<div className="h-80 w-full flex items-center justify-center">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={salesByProduct} margin={{ top: 5, right: 30, left: 20, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" stroke={CHART_GRID_COLOR} vertical={false} />
<CartesianGrid strokeDasharray="3 3" stroke="#222222" vertical={false} />
<XAxis
dataKey="name" stroke={CHART_AXIS_COLOR} fontSize={10} tickLine={false} axisLine={false}
dataKey="name" stroke="#888888" fontSize={10} tickLine={false} axisLine={false}
tick={false}
/>
<YAxis stroke={CHART_AXIS_COLOR} fontSize={12} tickLine={false} axisLine={false} />
<Tooltip content={<CustomTooltip />} cursor={{ fill: CHART_CURSOR_COLOR }} />
<YAxis stroke="#888888" fontSize={12} tickLine={false} axisLine={false} />
<Tooltip content={<CustomTooltip />} cursor={{ fill: '#222222' }} />
<Bar dataKey="value" radius={[4, 4, 0, 0]} onClick={(data) => { if(data?.payload?.id) navigate(`/products/${data.payload.id}`) }} style={{ cursor: 'pointer' }}>
{salesByProduct.map((entry) => (
<Cell
key={`cell-${entry.name}`}
fill={entry.fill}
fillOpacity={BAR_FILL_OPACITY}
stroke={entry.fill}
strokeOpacity={BAR_STROKE_OPACITY}
strokeWidth={1.25}
style={{ ...getBarGlowStyle(entry.fill), cursor: 'pointer' }}
/>
<Cell key={`cell-${entry.name}`} fill={entry.fill} style={{ cursor: 'pointer' }} />
))}
</Bar>
</BarChart>
@@ -877,20 +207,12 @@ const Dashboard = () => {
<div className="h-80 w-full flex items-center justify-center">
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie data={revenueByProduct} cx="50%" cy="50%" innerRadius={80} outerRadius={110} paddingAngle={5} dataKey="value" onClick={(data) => { if(data?.payload?.id) navigate(`/products/${data.payload.id}`) }} style={{ cursor: 'pointer' }}>
<Pie data={revenueByProduct} cx="50%" cy="50%" innerRadius={80} outerRadius={110} paddingAngle={5} dataKey="value" stroke="none" onClick={(data) => { if(data?.payload?.id) navigate(`/products/${data.payload.id}`) }} style={{ cursor: 'pointer' }}>
{revenueByProduct.map((entry) => (
<Cell
key={`cell-${entry.name}`}
fill={entry.fill}
fillOpacity={PIE_FILL_OPACITY}
stroke={entry.fill}
strokeOpacity={BAR_STROKE_OPACITY}
strokeWidth={1.5}
style={getPieGlowStyle(entry.fill)}
/>
<Cell key={`cell-${entry.name}`} fill={entry.fill} style={{ cursor: 'pointer' }} />
))}
</Pie>
<Tooltip content={<CustomTooltip isCurrency={true} />} cursor={{ fill: CHART_CURSOR_COLOR }} />
<Tooltip content={<CustomTooltip isCurrency={true} />} cursor={{ fill: '#222222' }} />
</PieChart>
</ResponsiveContainer>
</div>
@@ -904,8 +226,6 @@ const Dashboard = () => {
</div>
</div>
</div>
</div>
)}
</div>
);
};

View File

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

View File

@@ -1,433 +0,0 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { AlertTriangle, CheckCircle2, ChevronLeft, ChevronRight, CloudDownload, Database, Download, FileUp, Link2, Loader2, RefreshCw, ScrollText, Square, Timer, X, XCircle } from 'lucide-react';
import { exportMissingProductionOrderDataCsv, fetchOlistRunDetails, fetchOlistStatus, fetchProductionOrderImportData, getOlistAuthorizationUrl, importFinalizedProductionOrderCsv, startOlistCompositionSync, stopOlistCompositionSync } from '../dataService';
import type { OlistRunDetails, OlistStatus, OlistSyncRun, ProductionOrderImportData } from '../types';
const formatDateTime = (value: string | null) => {
if (!value) return '—';
const date = new Date(value);
return Number.isNaN(date.getTime()) ? '—' : new Intl.DateTimeFormat('pt-BR', {
dateStyle: 'short',
timeStyle: 'medium'
}).format(date);
};
const formatDuration = (startedAt: string | null, endedAt: string | null, now: number) => {
if (!startedAt) return '—';
const start = new Date(startedAt).getTime();
const end = endedAt ? new Date(endedAt).getTime() : now;
if (Number.isNaN(start) || Number.isNaN(end)) return '—';
const totalSeconds = Math.max(0, Math.floor((end - start) / 1000));
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = totalSeconds % 60;
return hours ? `${hours}h ${minutes}m` : `${minutes}m ${seconds}s`;
};
const runTone = (run: OlistSyncRun) => (
run.status === 'failed' ? 'text-red-300 border-red-400/30 bg-red-400/10'
: run.status === 'running' ? 'text-sky-300 border-sky-400/30 bg-sky-400/10'
: run.status === 'cancelled' ? 'text-amber-200 border-amber-400/30 bg-amber-400/10'
: 'text-emerald-300 border-emerald-400/30 bg-emerald-400/10'
);
const runStatusLabel = (run: OlistSyncRun) => (
run.status === 'running' ? (run.cancelRequestedAt ? 'Parando' : 'Em andamento')
: run.status === 'failed' ? 'Falhou'
: run.status === 'cancelled' ? 'Interrompida'
: 'Concluída'
);
const yieldFamilyLabels: Record<string, string> = {
BLCS: 'Camiseta regular',
BLOS: 'Oversize',
BLMC: 'Moletom canguru',
BLPM: 'Camiseta Pima',
BLRM: 'Regata'
};
const PAGE_SIZE = 5;
const PaginationControls = ({
page,
pageSize,
total,
onChange,
label
}: {
page: number;
pageSize: number;
total: number;
onChange: (page: number) => void;
label: string;
}) => {
const totalPages = Math.max(1, Math.ceil(total / pageSize));
const start = total ? ((page - 1) * pageSize) + 1 : 0;
const end = Math.min(page * pageSize, total);
return <div className="flex flex-wrap items-center justify-between gap-3 border-t border-dark-border px-4 py-3">
<span className="text-xs font-semibold text-dark-muted">{total ? `${label} ${start}-${end} de ${total.toLocaleString('pt-BR')}` : `Nenhum ${label.toLowerCase()}`}</span>
<div className="flex items-center gap-2">
<button type="button" onClick={() => onChange(page - 1)} disabled={page <= 1} aria-label="Página anterior" title="Página anterior" className="inline-flex h-8 w-8 items-center justify-center rounded-lg border border-dark-border text-dark-muted transition-colors hover:border-brand-primary hover:text-dark-text disabled:cursor-not-allowed disabled:opacity-40 cursor-pointer"><ChevronLeft className="h-4 w-4" /></button>
<span className="min-w-16 text-center text-xs font-bold text-dark-muted">Página {page} de {totalPages}</span>
<button type="button" onClick={() => onChange(page + 1)} disabled={page >= totalPages} aria-label="Próxima página" title="Próxima página" className="inline-flex h-8 w-8 items-center justify-center rounded-lg border border-dark-border text-dark-muted transition-colors hover:border-brand-primary hover:text-dark-text disabled:cursor-not-allowed disabled:opacity-40 cursor-pointer"><ChevronRight className="h-4 w-4" /></button>
</div>
</div>;
};
const OlistSync = () => {
const [status, setStatus] = useState<OlistStatus | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [isActionLoading, setIsActionLoading] = useState(false);
const [error, setError] = useState('');
const [now, setNow] = useState(0);
const [selectedRunId, setSelectedRunId] = useState<number | null>(null);
const [runDetails, setRunDetails] = useState<OlistRunDetails | null>(null);
const [detailsPages, setDetailsPages] = useState({ events: 1, products: 1 });
const [isDetailsLoading, setIsDetailsLoading] = useState(false);
const [importData, setImportData] = useState<ProductionOrderImportData | null>(null);
const [isImportingOrders, setIsImportingOrders] = useState(false);
const [isExportingPendingOrders, setIsExportingPendingOrders] = useState(false);
const [importMessage, setImportMessage] = useState('');
const [syncRunsPage, setSyncRunsPage] = useState(1);
const [importRunsPage, setImportRunsPage] = useState(1);
const [supplierReferencesPage, setSupplierReferencesPage] = useState(1);
const [importView, setImportView] = useState<'suppliers' | 'files'>('suppliers');
const loadStatus = useCallback(async (initial = false) => {
if (initial) setIsLoading(true);
try {
setStatus(await fetchOlistStatus(syncRunsPage, PAGE_SIZE));
setError('');
} catch (loadError) {
setError(loadError instanceof Error ? loadError.message : 'Não foi possível consultar a Olist.');
} finally {
if (initial) setIsLoading(false);
}
}, [syncRunsPage]);
useEffect(() => {
const loadTimeout = window.setTimeout(() => void loadStatus(true), 0);
const pollInterval = window.setInterval(() => void loadStatus(), 5_000);
const clockInterval = window.setInterval(() => setNow(Date.now()), 1_000);
return () => {
window.clearTimeout(loadTimeout);
window.clearInterval(pollInterval);
window.clearInterval(clockInterval);
};
}, [loadStatus]);
const loadRunDetails = useCallback(async (runId: number, pages: { events: number; products: number }) => {
setIsDetailsLoading(true);
try {
setRunDetails(await fetchOlistRunDetails(runId, pages.events, pages.products));
} catch (loadError) {
setError(loadError instanceof Error ? loadError.message : 'Não foi possível consultar os detalhes da execução.');
} finally {
setIsDetailsLoading(false);
}
}, []);
const loadImportData = useCallback(async () => {
try {
setImportData(await fetchProductionOrderImportData(importRunsPage, PAGE_SIZE));
} catch (loadError) {
setError(loadError instanceof Error ? loadError.message : 'Não foi possível consultar as importações de OP.');
}
}, [importRunsPage]);
useEffect(() => {
const timeoutId = window.setTimeout(() => void loadImportData(), 0);
return () => window.clearTimeout(timeoutId);
}, [loadImportData]);
useEffect(() => {
if (!selectedRunId || runDetails?.run.status !== 'running') return;
const intervalId = window.setInterval(() => void loadRunDetails(selectedRunId, detailsPages), 5_000);
return () => window.clearInterval(intervalId);
}, [selectedRunId, runDetails?.run.status, detailsPages, loadRunDetails]);
const latestRun = status?.latestRun || null;
const tokenExpired = Boolean(status?.tokenExpiresAt && new Date(status.tokenExpiresAt).getTime() <= now);
const completionPercent = useMemo(() => {
const cache = status?.productCache;
if (!cache?.total) return 0;
return Math.round((cache.checked / cache.total) * 100);
}, [status?.productCache]);
const supplierReferences = useMemo(() => (importData?.supplierRecommendations || [])
.filter(item => item.recommendedUnitsPerRoll)
.sort((left, right) => left.familyKey.localeCompare(right.familyKey) || left.supplier.localeCompare(right.supplier)), [importData?.supplierRecommendations]);
const supplierReferencesTotalPages = Math.max(1, Math.ceil(supplierReferences.length / PAGE_SIZE));
const visibleSupplierReferences = supplierReferences.slice((Math.min(supplierReferencesPage, supplierReferencesTotalPages) - 1) * PAGE_SIZE, Math.min(supplierReferencesPage, supplierReferencesTotalPages) * PAGE_SIZE);
const connect = async () => {
setIsActionLoading(true);
try {
window.location.assign(await getOlistAuthorizationUrl());
} catch (actionError) {
setError(actionError instanceof Error ? actionError.message : 'Não foi possível iniciar a conexão Olist.');
setIsActionLoading(false);
}
};
const startSync = async () => {
setIsActionLoading(true);
try {
await startOlistCompositionSync(!status?.hasCompletedRun);
await loadStatus();
} catch (actionError) {
setError(actionError instanceof Error ? actionError.message : 'Não foi possível iniciar a sincronização.');
} finally {
setIsActionLoading(false);
}
};
const stopSync = async () => {
if (!window.confirm('Interromper a sincronização atual? Os produtos já concluídos serão preservados e a próxima sincronização continuará de onde parou.')) return;
setIsActionLoading(true);
try {
await stopOlistCompositionSync();
await loadStatus();
} catch (actionError) {
setError(actionError instanceof Error ? actionError.message : 'Não foi possível interromper a sincronização.');
} finally {
setIsActionLoading(false);
}
};
const importOrders = async (file: File | null) => {
if (!file) return;
if (!file.name.toLowerCase().endsWith('.csv')) {
setError('Selecione um arquivo CSV de OPs finalizadas.');
return;
}
setIsImportingOrders(true);
setImportMessage('');
try {
const result = await importFinalizedProductionOrderCsv(file.name, await file.text());
setImportRunsPage(1);
setImportData({
runs: result.runs,
runsTotal: result.runsTotal,
runsPage: result.runsPage,
runsPageSize: result.runsPageSize,
recommendations: result.recommendations,
supplierRecommendations: result.supplierRecommendations
});
setImportMessage(`${result.imported.toLocaleString('pt-BR')} novas · ${result.updated.toLocaleString('pt-BR')} atualizadas · ${result.skipped.toLocaleString('pt-BR')} ignoradas.`);
} catch (actionError) {
setError(actionError instanceof Error ? actionError.message : 'Não foi possível importar as OPs finalizadas.');
} finally {
setIsImportingOrders(false);
}
};
const exportPendingOrders = async () => {
setIsExportingPendingOrders(true);
setImportMessage('');
try {
const totalRows = await exportMissingProductionOrderDataCsv();
setImportMessage(totalRows ? `${totalRows.toLocaleString('pt-BR')} OPs com pendências exportadas para completar.` : 'Nenhuma OP finalizada possui pendências de dados.');
} catch (actionError) {
setError(actionError instanceof Error ? actionError.message : 'Não foi possível exportar as pendências das OPs.');
} finally {
setIsExportingPendingOrders(false);
}
};
const openRunDetails = (runId: number) => {
const initialPages = { events: 1, products: 1 };
setDetailsPages(initialPages);
setSelectedRunId(runId);
void loadRunDetails(runId, initialPages);
};
if (isLoading && !status) {
return <div className="flex h-64 items-center justify-center rounded-2xl border border-dark-border bg-dark-card text-brand-primary"><Loader2 className="h-7 w-7 animate-spin" /></div>;
}
return (
<div className="space-y-6">
<div className="flex flex-col gap-4 xl:flex-row xl:items-start xl:justify-between">
<div>
<h1 className="text-2xl font-bold text-dark-text">Olist</h1>
<p className="mt-2 font-medium text-dark-muted">Monitoramento da conexão e da sincronização de composições.</p>
</div>
<div className="flex flex-wrap gap-2">
<button type="button" onClick={() => void loadStatus()} className="inline-flex h-10 items-center gap-2 rounded-xl border border-dark-border bg-dark-card px-4 text-sm font-bold text-dark-text transition-colors hover:border-brand-primary cursor-pointer">
<RefreshCw className="h-4 w-4" /> Atualizar
</button>
<button type="button" onClick={() => void exportPendingOrders()} disabled={isExportingPendingOrders} className="inline-flex h-10 items-center gap-2 rounded-xl border border-dark-border bg-dark-card px-4 text-sm font-bold text-dark-text transition-colors hover:border-brand-primary disabled:cursor-not-allowed disabled:opacity-60 cursor-pointer">
{isExportingPendingOrders ? <Loader2 className="h-4 w-4 animate-spin" /> : <Download className="h-4 w-4 text-brand-primary" />} Exportar pendências
</button>
<label className={`inline-flex h-10 items-center gap-2 rounded-xl border border-dark-border bg-dark-card px-4 text-sm font-bold text-dark-text transition-colors hover:border-brand-primary ${isImportingOrders ? 'cursor-wait opacity-60' : 'cursor-pointer'}`}>
{isImportingOrders ? <Loader2 className="h-4 w-4 animate-spin" /> : <FileUp className="h-4 w-4 text-brand-primary" />} Importar OPs finalizadas
<input type="file" accept=".csv,text/csv" className="sr-only" disabled={isImportingOrders} onChange={(event) => { void importOrders(event.target.files?.[0] || null); event.currentTarget.value = ''; }} />
</label>
{!status?.connected ? (
<button type="button" onClick={() => void connect()} disabled={!status?.configured || isActionLoading} className="inline-flex h-10 items-center gap-2 rounded-xl bg-brand-primary px-4 text-sm font-bold text-brand-contrast transition-colors hover:bg-brand-primary/90 disabled:cursor-not-allowed disabled:opacity-50 cursor-pointer">
{isActionLoading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Link2 className="h-4 w-4" />} Conectar Olist
</button>
) : (
<>
<button type="button" onClick={() => void connect()} disabled={!status?.configured || isActionLoading} className="inline-flex h-10 items-center gap-2 rounded-xl border border-dark-border bg-dark-card px-4 text-sm font-bold text-dark-text transition-colors hover:border-brand-primary disabled:cursor-not-allowed disabled:opacity-50 cursor-pointer">
{isActionLoading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Link2 className="h-4 w-4" />} Reconectar Olist
</button>
<button type="button" onClick={() => void startSync()} disabled={isActionLoading || status.syncInProgress} className="inline-flex h-10 items-center gap-2 rounded-xl bg-brand-primary px-4 text-sm font-bold text-brand-contrast transition-colors hover:bg-brand-primary/90 disabled:cursor-not-allowed disabled:opacity-50 cursor-pointer">
{isActionLoading || status.syncInProgress ? <Loader2 className="h-4 w-4 animate-spin" /> : <CloudDownload className="h-4 w-4" />} {status.syncInProgress ? 'Sincronizando' : 'Sincronizar agora'}
</button>
{status.syncInProgress && (
<button type="button" onClick={() => void stopSync()} disabled={isActionLoading || Boolean(latestRun?.cancelRequestedAt)} title="Parar sincronização" aria-label="Parar sincronização" className="inline-flex h-10 w-10 items-center justify-center rounded-xl border border-red-400/30 bg-red-400/10 text-red-300 transition-colors hover:bg-red-400/20 disabled:cursor-not-allowed disabled:opacity-50 cursor-pointer">
<Square className="h-4 w-4 fill-current" />
</button>
)}
</>
)}
</div>
</div>
{error && <div className="rounded-2xl border border-red-400/30 bg-red-400/10 px-4 py-3 text-sm font-bold text-red-300">{error}</div>}
{importMessage && <div className="rounded-2xl border border-emerald-400/30 bg-emerald-400/10 px-4 py-3 text-sm font-bold text-emerald-300">{importMessage.startsWith('Nenhuma') || importMessage.includes('exportadas') ? 'Exportação pronta: ' : 'Importação concluída: '}{importMessage}</div>}
{!status?.configured && <div className="rounded-2xl border border-amber-400/30 bg-amber-400/10 px-4 py-3 text-sm font-bold text-amber-200">Configure as credenciais Olist V3 no ambiente antes de conectar.</div>}
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
<div className="rounded-2xl border border-dark-border bg-dark-card p-5">
<div className="flex items-center justify-between"><span className="text-xs font-bold uppercase tracking-widest text-dark-muted">Conexão</span><Link2 className="h-4 w-4 text-brand-primary" /></div>
<p className={`mt-4 text-lg font-bold ${status?.connected && !tokenExpired ? 'text-emerald-300' : tokenExpired ? 'text-amber-200' : 'text-dark-text'}`}>{status?.connected ? (tokenExpired ? 'Reconexão necessária' : 'Conectada') : 'Não conectada'}</p>
<p className="mt-1 text-xs font-semibold text-dark-muted">{tokenExpired ? `Token expirado: ${formatDateTime(status?.tokenExpiresAt || null)}` : `Token expira: ${formatDateTime(status?.tokenExpiresAt || null)}`}</p>
</div>
<div className="rounded-2xl border border-dark-border bg-dark-card p-5">
<div className="flex items-center justify-between"><span className="text-xs font-bold uppercase tracking-widest text-dark-muted">Execução atual</span><Timer className="h-4 w-4 text-brand-primary" /></div>
<p className="mt-4 text-lg font-bold text-dark-text">{latestRun?.status === 'running' ? (latestRun.cancelRequestedAt ? 'Parando' : 'Em andamento') : 'Em espera'}</p>
<p className="mt-1 text-xs font-semibold text-dark-muted">{latestRun?.status === 'running' ? `Duração: ${formatDuration(latestRun.startedAt, null, now)}` : `Próxima automática: ${formatDateTime(status?.nextScheduledSyncAt || null)}`}</p>
</div>
<div className="rounded-2xl border border-dark-border bg-dark-card p-5">
<div className="flex items-center justify-between"><span className="text-xs font-bold uppercase tracking-widest text-dark-muted">Estruturas</span><Database className="h-4 w-4 text-brand-primary" /></div>
<p className="mt-4 text-lg font-bold text-dark-text">{status?.productCache.checked.toLocaleString('pt-BR') || 0} / {status?.productCache.total.toLocaleString('pt-BR') || 0}</p>
<p className="mt-1 text-xs font-semibold text-dark-muted">{completionPercent}% verificadas · {status?.productCache.pending || 0} pendentes</p>
</div>
</div>
{latestRun && (
<div className="rounded-2xl border border-dark-border bg-dark-card p-5">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h2 className="text-sm font-bold text-dark-text">Última execução</h2>
<p className="mt-1 text-xs font-semibold text-dark-muted">Início: {formatDateTime(latestRun.startedAt)} · {latestRun.mode === 'full' ? 'Carga completa' : 'Atualização incremental'}</p>
</div>
<span className={`inline-flex w-fit items-center gap-2 rounded-full border px-3 py-1 text-xs font-bold ${runTone(latestRun)}`}>
{latestRun.status === 'failed' ? <XCircle className="h-3.5 w-3.5" /> : latestRun.status === 'running' ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : latestRun.status === 'cancelled' ? <Square className="h-3.5 w-3.5 fill-current" /> : <CheckCircle2 className="h-3.5 w-3.5" />}
{runStatusLabel(latestRun)}
</span>
</div>
<div className="mt-5 grid grid-cols-2 gap-3 sm:grid-cols-4 lg:grid-cols-6">
{[
['Produtos verificados', latestRun.productsScanned],
['Fabricados', latestRun.manufacturedProducts],
['Composições', latestRun.imported],
['Componentes', latestRun.componentCount],
['Referências', latestRun.referenceCount],
['Falhas', latestRun.failed]
].map(([label, value]) => <div key={String(label)} 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">{label}</p><p className={`mt-2 text-lg font-bold ${label === 'Falhas' && Number(value) ? 'text-red-300' : 'text-dark-text'}`}>{Number(value).toLocaleString('pt-BR')}</p></div>)}
</div>
{latestRun.errorMessage && <p className="mt-4 rounded-xl border border-red-400/30 bg-red-400/10 p-3 text-sm font-semibold text-red-300">{latestRun.errorMessage}</p>}
{latestRun.details.length > 0 && <div className="mt-4 rounded-xl border border-amber-400/25 bg-amber-400/5 p-3 text-xs font-semibold text-amber-100"><div className="mb-2 flex items-center gap-2"><AlertTriangle className="h-4 w-4" /> Produtos com falha</div>{latestRun.details.slice(0, 5).map((detail, index) => <p key={`${detail.productId || index}-${detail.sku || ''}`}>{detail.sku || detail.productId}: {detail.error || 'Falha sem detalhe'}</p>)}</div>}
<button type="button" onClick={() => openRunDetails(latestRun.id)} className="mt-4 inline-flex h-9 items-center gap-2 rounded-lg border border-dark-border px-3 text-xs font-bold text-dark-text transition-colors hover:border-brand-primary cursor-pointer"><ScrollText className="h-4 w-4 text-brand-primary" /> Ver logs e produtos</button>
</div>
)}
<div className="overflow-hidden rounded-2xl border border-dark-border bg-dark-card">
<div className="border-b border-dark-border p-5"><h2 className="text-sm font-bold text-dark-text">Histórico de sincronizações</h2><p className="mt-1 text-xs font-semibold text-dark-muted">Execuções automáticas e manuais da sincronização de composições.</p></div>
<div className="divide-y divide-dark-border">
{!status?.runs.length && <div className="p-8 text-center text-sm font-semibold text-dark-muted">Nenhuma sincronização iniciada.</div>}
{status?.runs.map(run => <div key={run.id} className="flex flex-col gap-3 p-4 md:flex-row md:items-center md:justify-between"><div><p className="text-sm font-bold text-dark-text">{run.mode === 'full' ? 'Carga completa' : 'Atualização incremental'} · {run.trigger === 'scheduled' ? 'Automática' : 'Manual'}</p><p className="mt-1 text-xs font-semibold text-dark-muted">{formatDateTime(run.startedAt)} · duração {formatDuration(run.startedAt, run.status === 'running' ? null : run.completedAt, now)}</p></div><div className="flex flex-wrap items-center gap-3 text-xs font-bold text-dark-muted"><span>{run.imported} comp.</span><span>{run.referenceCount} refs.</span><span className={run.failed ? 'text-red-300' : ''}>{run.failed} falhas</span><span className={`rounded-full border px-2.5 py-1 ${runTone(run)}`}>{runStatusLabel(run)}</span><button type="button" onClick={() => openRunDetails(run.id)} title="Ver logs e produtos afetados" aria-label="Ver logs e produtos afetados" className="inline-flex h-8 w-8 items-center justify-center rounded-lg border border-dark-border text-brand-primary transition-colors hover:border-brand-primary cursor-pointer"><ScrollText className="h-4 w-4" /></button></div></div>)}
</div>
{!!status && <PaginationControls page={status.runsPage} pageSize={status.runsPageSize} total={status.runsTotal} label="Execuções" onChange={setSyncRunsPage} />}
</div>
<section className="overflow-hidden rounded-2xl border border-dark-border bg-dark-card">
<div className="flex flex-col gap-2 border-b border-dark-border p-5 sm:flex-row sm:items-center sm:justify-between">
<div>
<h2 className="text-sm font-bold text-dark-text">OPs finalizadas importadas</h2>
<p className="mt-1 text-xs font-semibold text-dark-muted">Histórico CSV para recomendações de rendimento. Reimportar o mesmo arquivo atualiza as OPs sem duplicá-las.</p>
</div>
<button type="button" onClick={() => void loadImportData()} title="Atualizar importações" aria-label="Atualizar importações" className="inline-flex h-8 w-8 items-center justify-center rounded-lg border border-dark-border text-brand-primary transition-colors hover:border-brand-primary cursor-pointer"><RefreshCw className="h-4 w-4" /></button>
</div>
<div className="grid grid-cols-1 gap-3 p-5 sm:grid-cols-2 xl:grid-cols-5">
{(['BLCS', 'BLOS', 'BLMC', 'BLPM', 'BLRM'] as const).map(familyKey => {
const recommendation = importData?.recommendations.find(item => item.familyKey === familyKey);
return <div key={familyKey} className="rounded-xl border border-dark-border bg-dark-input/50 p-3">
<p className="text-[10px] font-bold uppercase tracking-widest text-dark-muted">{familyKey} · {yieldFamilyLabels[familyKey]}</p>
<p className="mt-2 text-lg font-bold text-dark-text">{recommendation?.recommendedUnitsPerRoll ? `${Math.round(recommendation.recommendedUnitsPerRoll).toLocaleString('pt-BR')} un./rolo` : 'Sem sugestão'}</p>
<p className="mt-1 text-xs font-semibold text-dark-muted">{recommendation ? `${recommendation.sampleCount.toLocaleString('pt-BR')} OPs · ${recommendation.yieldSampleCount.toLocaleString('pt-BR')} c/ rendimento` : 'Nenhuma OP importada'}</p>
</div>;
})}
</div>
<div className="border-t border-dark-border p-5">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="inline-flex w-fit rounded-xl border border-dark-border bg-dark-input/50 p-1">
<button type="button" onClick={() => setImportView('suppliers')} className={`rounded-lg px-3 py-2 text-xs font-bold transition-colors cursor-pointer ${importView === 'suppliers' ? 'bg-brand-primary text-brand-contrast' : 'text-dark-muted hover:text-dark-text'}`}>Referências por fornecedor</button>
<button type="button" onClick={() => setImportView('files')} className={`rounded-lg px-3 py-2 text-xs font-bold transition-colors cursor-pointer ${importView === 'files' ? 'bg-brand-primary text-brand-contrast' : 'text-dark-muted hover:text-dark-text'}`}>Arquivos importados</button>
</div>
<p className="text-xs font-bold text-dark-muted">{importView === 'suppliers' ? `${supplierReferences.length.toLocaleString('pt-BR')} referências` : `${(importData?.runsTotal || 0).toLocaleString('pt-BR')} arquivos`}</p>
</div>
<div className="mt-4 overflow-hidden rounded-xl border border-dark-border">
{importView === 'suppliers' ? <>
<div className="grid grid-cols-[minmax(0,1fr)_auto] gap-3 border-b border-dark-border bg-dark-input/50 px-4 py-3 text-[10px] font-bold uppercase tracking-widest text-dark-muted"><span>Fornecedor e família</span><span>Rendimento</span></div>
<div className="divide-y divide-dark-border">
{!supplierReferences.length && <p className="p-5 text-sm font-semibold text-dark-muted">Nenhuma referência por fornecedor disponível.</p>}
{visibleSupplierReferences.map(item => <div key={`${item.familyKey}-${item.supplier}`} className="grid grid-cols-[minmax(0,1fr)_auto] gap-3 px-4 py-3 sm:items-center"><div><p className="text-sm font-bold text-dark-text">{item.supplier}</p><p className="mt-1 text-xs font-semibold text-dark-muted">{item.familyKey} · {yieldFamilyLabels[item.familyKey] || 'Família de corte'} · {item.sampleCount.toLocaleString('pt-BR')} OPs</p></div><p className="text-sm font-bold text-brand-primary">{Math.round(item.recommendedUnitsPerRoll!).toLocaleString('pt-BR')} un./rolo</p></div>)}
</div>
{!!supplierReferences.length && <PaginationControls page={Math.min(supplierReferencesPage, supplierReferencesTotalPages)} pageSize={PAGE_SIZE} total={supplierReferences.length} label="Referências" onChange={setSupplierReferencesPage} />}
</> : <>
<div className="grid grid-cols-[minmax(0,1fr)_auto] gap-3 border-b border-dark-border bg-dark-input/50 px-4 py-3 text-[10px] font-bold uppercase tracking-widest text-dark-muted"><span>Arquivo</span><span>Status da importação</span></div>
<div className="divide-y divide-dark-border">
{!importData?.runs.length && <p className="p-5 text-sm font-semibold text-dark-muted">Nenhum CSV de OPs finalizadas importado.</p>}
{importData?.runs.map(run => <div key={run.id} className="grid gap-3 px-4 py-4 lg:grid-cols-[minmax(0,1fr)_auto] lg:items-center"><div className="min-w-0"><p className="truncate text-sm font-bold text-dark-text">{run.filename}</p><p className="mt-1 text-xs font-semibold text-dark-muted">{formatDateTime(run.startedAt)} · {run.totalRows.toLocaleString('pt-BR')} linhas</p>{run.errorMessage && <p className="mt-1 line-clamp-1 text-xs font-semibold text-amber-200">{run.errorMessage}</p>}</div><div className="flex flex-wrap gap-x-3 gap-y-1 text-xs font-bold text-dark-muted"><span>{run.imported} novas</span><span>{run.updated} atualizadas</span><span>{run.skipped} ignoradas</span><span className={run.failed ? 'text-red-300' : ''}>{run.failed} falhas</span></div></div>)}
</div>
{!!importData && <PaginationControls page={importData.runsPage} pageSize={importData.runsPageSize} total={importData.runsTotal} label="Arquivos" onChange={setImportRunsPage} />}
</>}
</div>
</div>
</section>
{selectedRunId && (
<div className="fixed inset-0 z-50 flex items-end justify-center bg-black/70 p-0 backdrop-blur-sm sm:items-center sm:p-6" role="dialog" aria-modal="true" aria-label="Logs e produtos afetados da sincronização Olist">
<div className="max-h-[92vh] w-full max-w-6xl overflow-y-auto rounded-t-2xl border border-dark-border bg-dark-card shadow-2xl sm:rounded-2xl">
<div className="sticky top-0 z-10 flex items-start justify-between border-b border-dark-border bg-dark-card p-5">
<div>
<h2 className="text-base font-bold text-dark-text">Logs e produtos afetados</h2>
<p className="mt-1 text-xs font-semibold text-dark-muted">{runDetails ? `${runDetails.run.mode === 'full' ? 'Carga completa' : 'Atualização incremental'} · ${formatDateTime(runDetails.run.startedAt)}` : 'Carregando execução...'}</p>
</div>
<button type="button" onClick={() => { setSelectedRunId(null); setRunDetails(null); }} title="Fechar" aria-label="Fechar" className="inline-flex h-9 w-9 items-center justify-center rounded-lg border border-dark-border text-dark-muted transition-colors hover:border-brand-primary hover:text-dark-text cursor-pointer"><X className="h-4 w-4" /></button>
</div>
{isDetailsLoading && !runDetails ? (
<div className="flex h-56 items-center justify-center"><Loader2 className="h-6 w-6 animate-spin text-brand-primary" /></div>
) : runDetails ? (
<div className="grid gap-5 p-5 lg:grid-cols-2">
<section className="overflow-hidden rounded-xl border border-dark-border">
<div className="flex items-center justify-between border-b border-dark-border px-4 py-3"><div><h3 className="text-sm font-bold text-dark-text">Logs</h3><p className="mt-0.5 text-xs font-semibold text-dark-muted">{runDetails.events.total.toLocaleString('pt-BR')} eventos</p></div>{isDetailsLoading && <Loader2 className="h-4 w-4 animate-spin text-brand-primary" />}</div>
<div className="divide-y divide-dark-border">
{!runDetails.events.items.length && <p className="p-5 text-sm font-semibold text-dark-muted">Nenhum evento registrado ainda.</p>}
{runDetails.events.items.map(event => <div key={event.id} className="p-4"><div className="flex items-start gap-2"><span className={`mt-1.5 h-2 w-2 shrink-0 rounded-full ${event.level === 'error' ? 'bg-red-400' : 'bg-sky-400'}`} /><div className="min-w-0"><p className="text-sm font-semibold text-dark-text">{event.message}</p>{event.sku && <p className="mt-1 truncate text-xs font-bold text-brand-primary">{event.sku} · {event.productDescription}</p>}<p className="mt-1 text-[11px] font-semibold text-dark-muted">{formatDateTime(event.createdAt)}</p></div></div></div>)}
</div>
<div className="flex items-center justify-between border-t border-dark-border p-3"><button type="button" onClick={() => { const pages = { ...detailsPages, events: Math.max(1, detailsPages.events - 1) }; setDetailsPages(pages); void loadRunDetails(runDetails.run.id, pages); }} disabled={runDetails.events.page === 1 || isDetailsLoading} className="text-xs font-bold text-dark-muted disabled:opacity-40 cursor-pointer">Anterior</button><span className="text-xs font-semibold text-dark-muted">Página {runDetails.events.page}</span><button type="button" onClick={() => { const pages = { ...detailsPages, events: detailsPages.events + 1 }; setDetailsPages(pages); void loadRunDetails(runDetails.run.id, pages); }} disabled={runDetails.events.page * runDetails.events.pageSize >= runDetails.events.total || isDetailsLoading} className="text-xs font-bold text-brand-primary disabled:opacity-40 cursor-pointer">Próxima</button></div>
</section>
<section className="overflow-hidden rounded-xl border border-dark-border">
<div className="border-b border-dark-border px-4 py-3"><h3 className="text-sm font-bold text-dark-text">Produtos afetados</h3><p className="mt-0.5 text-xs font-semibold text-dark-muted">{runDetails.affectedProducts.total.toLocaleString('pt-BR')} composições importadas nesta execução</p></div>
<div className="divide-y divide-dark-border">
{!runDetails.affectedProducts.items.length && <p className="p-5 text-sm font-semibold text-dark-muted">Nenhuma composição importada nesta execução.</p>}
{runDetails.affectedProducts.items.map(product => <div key={product.id} className="p-4"><p className="text-sm font-bold text-dark-text">{product.productDescription || product.sku || product.productId}</p><p className="mt-1 text-xs font-semibold text-brand-primary">{product.sku || `ID ${product.productId}`}</p><p className="mt-2 text-xs font-semibold text-dark-muted">{Number(product.metadata.componentCount || 0).toLocaleString('pt-BR')} componentes · {Number(product.metadata.referenceCount || 0).toLocaleString('pt-BR')} referências</p></div>)}
</div>
<div className="flex items-center justify-between border-t border-dark-border p-3"><button type="button" onClick={() => { const pages = { ...detailsPages, products: Math.max(1, detailsPages.products - 1) }; setDetailsPages(pages); void loadRunDetails(runDetails.run.id, pages); }} disabled={runDetails.affectedProducts.page === 1 || isDetailsLoading} className="text-xs font-bold text-dark-muted disabled:opacity-40 cursor-pointer">Anterior</button><span className="text-xs font-semibold text-dark-muted">Página {runDetails.affectedProducts.page}</span><button type="button" onClick={() => { const pages = { ...detailsPages, products: detailsPages.products + 1 }; setDetailsPages(pages); void loadRunDetails(runDetails.run.id, pages); }} disabled={runDetails.affectedProducts.page * runDetails.affectedProducts.pageSize >= runDetails.affectedProducts.total || isDetailsLoading} className="text-xs font-bold text-brand-primary disabled:opacity-40 cursor-pointer">Próxima</button></div>
</section>
</div>
) : <div className="p-8 text-center text-sm font-semibold text-dark-muted">Não foi possível carregar os detalhes desta execução.</div>}
</div>
</div>
)}
</div>
);
};
export default OlistSync;

View File

@@ -1,903 +0,0 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { Link, useOutletContext } from 'react-router-dom';
import { AlertTriangle, CheckCircle2, Download, Eye, History, PackageSearch, Pencil, RotateCcw, Save, Search, X } from 'lucide-react';
import DateRangePicker from '../components/DateRangePicker';
import PaginationControls from '../components/PaginationControls';
import ProductTypeBadge from '../components/ProductTypeBadge';
import RefreshStatus from '../components/RefreshStatus';
import SkuPlanningModal from '../components/SkuPlanningModal';
import { classifyCutFamily, CUT_FAMILY_RULES, type CutFamilyKey } from '../analytics/cutting';
import { deleteGraphsStockBalance, exportToCSV, fetchCuttingSettings, fetchGraphsStockBalanceHistory, fetchProductAnalytics, fetchStock, fetchSupplySummary, saveCuttingSettings, saveGraphsStockBalance } from '../dataService';
import { getProductTypeConfig, resolveProductType, type ProductTypeKey } from '../productClassification';
import { parseProductName } from '../productParsing';
import type { CutProductOverride, CuttingSettings, DateRange, GraphsStockBalanceHistoryItem, ProductAnalyticsItem, StockData, SupplySummary } from '../types';
type IssueKey = 'review_type' | 'missing_cut_family' | 'missing_color' | 'missing_size' | 'missing_yield';
type IssueFilter = 'all' | IssueKey;
type ExceptionQueue = 'negative_stock' | 'missing_composition' | 'cutting_data';
type MissingCompositionRow = {
productId: string;
name: string;
quantitySold: number;
stockQuantity: number;
suggestedQuantity: number;
};
type PlanningIssueRow = ProductAnalyticsItem & {
productType: ProductTypeKey;
color: string;
size: string;
familyKey: CutFamilyKey | '';
familyLabel: string;
issues: IssueKey[];
priorityScore: number;
hasOverride: boolean;
};
const issueLabels: Record<IssueKey, string> = {
review_type: 'Tipo',
missing_cut_family: 'Família',
missing_color: 'Cor',
missing_size: 'Tamanho',
missing_yield: 'Rendimento'
};
const issueHelp: Record<IssueKey, string> = {
review_type: 'O classificador não conseguiu identificar o tipo do SKU.',
missing_cut_family: 'SKU de vestuário não caiu em uma família de corte confiável.',
missing_color: 'SKU de vestuário não tem cor clara para planejamento.',
missing_size: 'SKU de vestuário não tem tamanho claro para planejamento.',
missing_yield: 'A família existe, mas ainda falta rendimento em unidades por rolo.'
};
const issueFilters: Array<{ value: IssueFilter; label: string }> = [
{ value: 'all', label: 'Todos' },
{ value: 'review_type', label: issueLabels.review_type },
{ value: 'missing_cut_family', label: issueLabels.missing_cut_family },
{ value: 'missing_color', label: issueLabels.missing_color },
{ value: 'missing_size', label: issueLabels.missing_size },
{ value: 'missing_yield', label: issueLabels.missing_yield }
];
const familyLabels = new Map<CutFamilyKey, string>([
...CUT_FAMILY_RULES.map(rule => [rule.key, `${rule.materialLabel} · ${rule.label}`] as const),
['OUTROS', 'Sem regra']
]);
const formatNumber = (value: number, maximumFractionDigits = 0) => (
new Intl.NumberFormat('pt-BR', { maximumFractionDigits }).format(value)
);
const formatCurrency = (value: number) => (
new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(value)
);
const emptySupplySummary: SupplySummary = {
receipts: [],
lots: [],
movements: [],
fabricPlans: [],
purchaseNeeds: [],
stats: {
totalQuantityKg: 0,
activeLots: 0,
rolls: 0,
alerts: 0,
pendingReceipts: 0,
approvedReceipts: 0,
},
};
const buildIssues = (
product: ProductAnalyticsItem,
settings: CuttingSettings
): PlanningIssueRow => {
const override = settings.productOverrides[product.id];
const metadata = parseProductName(product.name);
const productType = resolveProductType(product.name, override);
const familyKey = override?.familyKey || classifyCutFamily(metadata.baseName).key;
const color = override?.color || metadata.color;
const size = (override?.size || metadata.size).toUpperCase();
const issues: IssueKey[] = [];
if (productType === 'unknown') issues.push('review_type');
if (productType === 'finished_apparel') {
if (familyKey === 'OUTROS') issues.push('missing_cut_family');
if (!color) issues.push('missing_color');
if (!size) issues.push('missing_size');
if (familyKey !== 'OUTROS' && !settings.familyYields[familyKey]) issues.push('missing_yield');
}
return {
...product,
productType,
color,
size,
familyKey,
familyLabel: familyLabels.get(familyKey) || '-',
issues,
priorityScore: (product.quantitySold * 2) + (product.revenue / 100),
hasOverride: Boolean(override)
};
};
const PlanningIssuesSkeleton = () => (
<div className="overflow-hidden rounded-2xl border border-dark-border bg-dark-card shadow-sm" aria-label="Carregando dados pendentes">
<div className="border-b border-dark-border p-4">
<div className="grid grid-cols-[120px_1.4fr_150px_170px_130px_130px_120px] gap-5">
{[0, 1, 2, 3, 4, 5, 6].map(item => <div key={item} className="skeleton h-3" />)}
</div>
</div>
<div className="divide-y divide-dark-border">
{[0, 1, 2, 3, 4, 5].map(row => (
<div key={row} className="grid grid-cols-[120px_1.4fr_150px_170px_130px_130px_120px] gap-5 px-6 py-4">
{[0, 1, 2, 3, 4, 5, 6].map(item => <div key={item} className="skeleton h-4" />)}
</div>
))}
</div>
</div>
);
const formatDate = (value: string | null | undefined) => {
if (!value) return '—';
const date = new Date(value);
return Number.isNaN(date.getTime()) ? '—' : new Intl.DateTimeFormat('pt-BR', { dateStyle: 'short' }).format(date);
};
const StockGraphsBalanceModal = ({
stock,
onClose,
onSaved
}: {
stock: StockData;
onClose: () => void;
onSaved: (stock: StockData) => void;
}) => {
const [balance, setBalance] = useState(stock.graphs_balance === null || stock.graphs_balance === undefined ? '' : String(stock.graphs_balance));
const [reason, setReason] = useState(stock.graphs_balance_reason || 'Conferência física de estoque');
const [effectiveAt, setEffectiveAt] = useState(stock.graphs_balance_effective_at?.slice(0, 10) || new Date().toISOString().slice(0, 10));
const [history, setHistory] = useState<GraphsStockBalanceHistoryItem[]>([]);
const [isLoadingHistory, setIsLoadingHistory] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const [error, setError] = useState('');
const loadHistory = useCallback(async () => {
setIsLoadingHistory(true);
try {
setHistory(await fetchGraphsStockBalanceHistory(stock.produto_id));
} catch (historyError) {
setError(historyError instanceof Error ? historyError.message : 'Não foi possível carregar o histórico.');
} finally {
setIsLoadingHistory(false);
}
}, [stock.produto_id]);
useEffect(() => {
const timer = window.setTimeout(() => { void loadHistory(); }, 0);
return () => window.clearTimeout(timer);
}, [loadHistory]);
const save = async () => {
const normalizedBalance = Number(balance.replace(',', '.'));
if (!Number.isFinite(normalizedBalance)) {
setError('Informe um saldo físico válido.');
return;
}
setIsSaving(true);
setError('');
try {
const saved = await saveGraphsStockBalance(stock.produto_id, { balance: normalizedBalance, reason, effectiveAt });
onSaved(saved);
setBalance(String(saved.graphs_balance ?? normalizedBalance));
await loadHistory();
} catch (saveError) {
setError(saveError instanceof Error ? saveError.message : 'Não foi possível salvar o saldo Graphs.');
} finally {
setIsSaving(false);
}
};
const remove = async () => {
if (!window.confirm('Remover o saldo Graphs e voltar a usar o saldo importado do Tiny no planejamento?')) return;
setIsSaving(true);
setError('');
try {
const saved = await deleteGraphsStockBalance(stock.produto_id);
onSaved(saved);
setBalance('');
await loadHistory();
} catch (removeError) {
setError(removeError instanceof Error ? removeError.message : 'Não foi possível remover o saldo Graphs.');
} finally {
setIsSaving(false);
}
};
const planningBalance = Math.max(0, Number(stock.planning_balance ?? stock.saldo));
return <div className="fixed inset-0 z-50 flex h-dvh items-center justify-center overflow-y-auto bg-black/75 p-4 backdrop-blur-md" role="dialog" aria-modal="true" aria-label={`Saldo Graphs de ${stock.nome}`} onClick={onClose}>
<div className="w-full max-w-2xl rounded-2xl border border-dark-border bg-dark-card shadow-2xl" onClick={(event) => event.stopPropagation()}>
<div className="flex items-start justify-between gap-4 border-b border-dark-border p-5">
<div className="min-w-0">
<p className="font-mono text-[10px] font-bold uppercase tracking-widest text-dark-muted">Material #{stock.produto_id}</p>
<h2 className="mt-1 truncate text-lg font-bold text-dark-text" title={stock.nome}>{stock.nome}</h2>
<p className="mt-2 text-sm font-semibold text-dark-muted">O saldo Tiny permanece preservado para auditoria. O saldo Graphs passa a ser a base do planejamento.</p>
</div>
<button type="button" onClick={onClose} title="Fechar" aria-label="Fechar" className="inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-xl border border-dark-border bg-dark-input text-dark-muted transition-colors hover:border-brand-primary hover:text-dark-text cursor-pointer"><X className="h-4 w-4" /></button>
</div>
<div className="grid gap-3 p-5 sm:grid-cols-3">
<div className="rounded-xl border border-dark-border bg-dark-input/50 p-3"><p className="text-[10px] font-bold uppercase tracking-widest text-dark-muted">Saldo Tiny</p><p className="mt-2 text-lg font-bold text-red-300">{formatNumber(Number(stock.saldo), 2)} un.</p></div>
<div className="rounded-xl border border-dark-border bg-dark-input/50 p-3"><p className="text-[10px] font-bold uppercase tracking-widest text-dark-muted">Saldo Graphs</p><p className="mt-2 text-lg font-bold text-brand-primary">{stock.graphs_balance === null || stock.graphs_balance === undefined ? 'Não informado' : `${formatNumber(Number(stock.graphs_balance), 2)} un.`}</p></div>
<div className="rounded-xl border border-dark-border bg-dark-input/50 p-3"><p className="text-[10px] font-bold uppercase tracking-widest text-dark-muted">Disponível no plano</p><p className="mt-2 text-lg font-bold text-emerald-300">{formatNumber(planningBalance, 2)} un.</p></div>
</div>
<div className="grid gap-4 border-t border-dark-border p-5 md:grid-cols-[minmax(0,1fr)_minmax(0,1.2fr)]">
<div className="space-y-4">
<label className="block space-y-2"><span className="text-xs font-bold uppercase tracking-widest text-dark-muted">Saldo físico no Graphs</span><input type="text" inputMode="decimal" value={balance} onChange={(event) => setBalance(event.target.value)} placeholder="Ex.: 850,50" className="h-10 w-full rounded-lg border border-dark-border bg-dark-input px-3 text-sm font-bold text-dark-text outline-none transition-colors placeholder:text-dark-muted focus:border-brand-primary" /></label>
<label className="block space-y-2"><span className="text-xs font-bold uppercase tracking-widest text-dark-muted">Data da conferência</span><input type="date" value={effectiveAt} onChange={(event) => setEffectiveAt(event.target.value)} className="h-10 w-full rounded-lg border border-dark-border bg-dark-input px-3 text-sm font-bold text-dark-text outline-none transition-colors focus:border-brand-primary" /></label>
<label className="block space-y-2"><span className="text-xs font-bold uppercase tracking-widest text-dark-muted">Motivo</span><textarea value={reason} onChange={(event) => setReason(event.target.value)} rows={3} placeholder="Ex.: inventário físico do depósito" className="w-full resize-none rounded-lg border border-dark-border bg-dark-input px-3 py-2 text-sm font-semibold text-dark-text outline-none transition-colors placeholder:text-dark-muted focus:border-brand-primary" /></label>
{error && <p className="rounded-lg border border-red-400/30 bg-red-400/10 px-3 py-2 text-xs font-bold text-red-300">{error}</p>}
<div className="flex flex-wrap justify-between gap-2"><button type="button" onClick={() => void remove()} disabled={isSaving || stock.graphs_balance === null || stock.graphs_balance === undefined} className="inline-flex h-10 items-center gap-2 rounded-lg border border-dark-border px-3 text-sm font-bold text-dark-muted transition-colors hover:border-red-400/40 hover:text-red-300 disabled:cursor-not-allowed disabled:opacity-40 cursor-pointer"><RotateCcw className="h-4 w-4" /> Usar Tiny</button><button type="button" onClick={() => void save()} disabled={isSaving} className="inline-flex h-10 items-center gap-2 rounded-lg bg-brand-primary px-4 text-sm font-bold text-brand-contrast transition-colors hover:bg-brand-primary/90 disabled:cursor-not-allowed disabled:opacity-50 cursor-pointer"><Save className="h-4 w-4" /> {isSaving ? 'Salvando...' : 'Salvar saldo Graphs'}</button></div>
</div>
<div className="overflow-hidden rounded-xl border border-dark-border">
<div className="flex items-center gap-2 border-b border-dark-border bg-dark-input/50 px-4 py-3"><History className="h-4 w-4 text-brand-primary" /><h3 className="text-sm font-bold text-dark-text">Histórico de alterações</h3></div>
<div className="max-h-80 divide-y divide-dark-border overflow-y-auto">
{isLoadingHistory && <p className="p-4 text-sm font-semibold text-dark-muted">Carregando histórico...</p>}
{!isLoadingHistory && !history.length && <p className="p-4 text-sm font-semibold text-dark-muted">Nenhuma alteração manual registrada.</p>}
{history.map(item => <div key={item.id} className="p-4"><div className="flex items-center justify-between gap-3"><p className="text-sm font-bold text-dark-text">{item.action === 'removed' ? 'Saldo Graphs removido' : `${formatNumber(Number(item.graphs_balance || 0), 2)} un.`}</p><span className="text-xs font-semibold text-dark-muted">{formatDate(item.created_at)}</span></div><p className="mt-1 text-xs font-semibold text-dark-muted">Tiny: {formatNumber(Number(item.tiny_balance || 0), 2)} un. · referência {formatDate(item.effective_at)}</p>{item.reason && <p className="mt-2 text-xs font-medium text-dark-text">{item.reason}</p>}{item.changed_by && <p className="mt-1 text-[11px] font-semibold text-dark-muted">Por {item.changed_by}</p>}</div>)}
</div>
</div>
</div>
</div>
</div>;
};
const PlanningIssues = () => {
const { dateRange, setDateRange } = useOutletContext<{
dateRange: DateRange,
setDateRange: (range: DateRange) => void
}>();
const [products, setProducts] = useState<ProductAnalyticsItem[]>([]);
const [settings, setSettings] = useState<CuttingSettings>({ familyYields: {}, productOverrides: {} });
const [supplySummary, setSupplySummary] = useState<SupplySummary>(emptySupplySummary);
const [stockSnapshot, setStockSnapshot] = useState<StockData[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [activeQueue, setActiveQueue] = useState<ExceptionQueue>('negative_stock');
const [searchTerm, setSearchTerm] = useState('');
const [issueFilter, setIssueFilter] = useState<IssueFilter>('all');
const [editingProduct, setEditingProduct] = useState<PlanningIssueRow | null>(null);
const [editingStock, setEditingStock] = useState<StockData | null>(null);
const [isSaving, setIsSaving] = useState(false);
const [currentPage, setCurrentPage] = useState(1);
const [itemsPerPage, setItemsPerPage] = useState(10);
useEffect(() => {
let isMounted = true;
const loadData = async () => {
setIsLoading(true);
const [productData, planningSettings, nextSupplySummary, nextStockSnapshot] = await Promise.all([
fetchProductAnalytics(dateRange),
fetchCuttingSettings(),
fetchSupplySummary(),
fetchStock(),
]);
if (isMounted) {
setProducts(productData);
setSettings(planningSettings);
setSupplySummary(nextSupplySummary);
setStockSnapshot(nextStockSnapshot);
setIsLoading(false);
}
};
void loadData();
return () => {
isMounted = false;
};
}, [dateRange]);
const rows = useMemo(() => {
const normalizedSearch = searchTerm.trim().toLowerCase();
return products
.map(product => buildIssues(product, settings))
.filter(row => row.issues.length > 0)
.filter(row => issueFilter === 'all' || row.issues.includes(issueFilter))
.filter(row => {
if (!normalizedSearch) return true;
const typeLabel = getProductTypeConfig(row.productType).label.toLowerCase();
return (
row.id.toLowerCase().includes(normalizedSearch) ||
row.name.toLowerCase().includes(normalizedSearch) ||
row.color.toLowerCase().includes(normalizedSearch) ||
row.size.toLowerCase().includes(normalizedSearch) ||
row.familyLabel.toLowerCase().includes(normalizedSearch) ||
typeLabel.includes(normalizedSearch)
);
})
.sort((a, b) => {
if (b.issues.length !== a.issues.length) return b.issues.length - a.issues.length;
return b.priorityScore - a.priorityScore;
});
}, [issueFilter, products, searchTerm, settings]);
const issueCounts = useMemo(() => {
const counts = new Map<IssueKey, number>();
products
.map(product => buildIssues(product, settings))
.forEach(row => row.issues.forEach(issue => counts.set(issue, (counts.get(issue) || 0) + 1)));
return counts;
}, [products, settings]);
const negativeStockRows = useMemo(() => (
stockSnapshot
.filter(item => Number(item.saldo || 0) < 0)
.sort((a, b) => Number(a.saldo) - Number(b.saldo))
), [stockSnapshot]);
const missingCompositionRows = useMemo(() => {
const byProductId = new Map<string, MissingCompositionRow>();
supplySummary.purchaseNeeds
.filter(need => need.missingReference)
.flatMap(need => need.products || [])
.forEach(product => {
if (!product.productId) return;
const existing = byProductId.get(product.productId);
if (existing) {
existing.quantitySold = Math.max(existing.quantitySold, Number(product.quantitySold || 0));
existing.stockQuantity = Math.max(existing.stockQuantity, Number(product.stockQuantity || 0));
existing.suggestedQuantity = Math.max(existing.suggestedQuantity, Number(product.suggestedQuantity || 0));
return;
}
byProductId.set(product.productId, {
productId: product.productId,
name: product.name,
quantitySold: Number(product.quantitySold || 0),
stockQuantity: Number(product.stockQuantity || 0),
suggestedQuantity: Number(product.suggestedQuantity || 0),
});
});
return Array.from(byProductId.values())
.sort((a, b) => b.suggestedQuantity - a.suggestedQuantity || b.quantitySold - a.quantitySold);
}, [supplySummary.purchaseNeeds]);
const normalizedSearch = searchTerm.trim().toLocaleLowerCase('pt-BR');
const visibleNegativeStockRows = useMemo(() => (
negativeStockRows.filter(row => (
!normalizedSearch || `${row.produto_id} ${row.nome}`.toLocaleLowerCase('pt-BR').includes(normalizedSearch)
))
), [negativeStockRows, normalizedSearch]);
const visibleMissingCompositionRows = useMemo(() => (
missingCompositionRows.filter(row => (
!normalizedSearch || `${row.productId} ${row.name}`.toLocaleLowerCase('pt-BR').includes(normalizedSearch)
))
), [missingCompositionRows, normalizedSearch]);
const saveProductOverride = async (productId: string, override: CutProductOverride | null) => {
const productOverrides = { ...settings.productOverrides };
if (override) {
productOverrides[productId] = override;
} else {
delete productOverrides[productId];
}
const nextSettings = { ...settings, productOverrides };
setIsSaving(true);
try {
const savedSettings = await saveCuttingSettings(nextSettings);
setSettings(savedSettings);
setEditingProduct(null);
} finally {
setIsSaving(false);
}
};
const saveGraphsBalance = (updatedStock: StockData) => {
setStockSnapshot(current => current.map(item => (
item.produto_id === updatedStock.produto_id ? updatedStock : item
)));
setEditingStock(updatedStock);
};
const exportActiveQueue = () => {
const dateSuffix = new Date().toISOString().slice(0, 10);
if (activeQueue === 'negative_stock') {
exportToCSV(visibleNegativeStockRows.map(row => ({
sku: row.produto_id,
material: row.nome,
saldo_tiny: Number(row.saldo || 0),
saldo_graphs: row.graphs_balance ?? '',
saldo_planejamento: Math.max(0, Number(row.planning_balance ?? row.saldo ?? 0))
})), `saldos-negativos-${dateSuffix}`);
return;
}
if (activeQueue === 'missing_composition') {
exportToCSV(visibleMissingCompositionRows.map(row => ({
sku: row.productId,
produto: row.name,
vendido: row.quantitySold,
estoque: row.stockQuantity,
corte_sugerido: row.suggestedQuantity,
pendencia: 'Sem composição confiável'
})), `skus-sem-composicao-${dateSuffix}`);
return;
}
exportToCSV(rows.map(row => ({
sku: row.id,
produto: row.name,
tipo: getProductTypeConfig(row.productType).label,
familia: row.familyLabel,
cor: row.color || 'Não informado',
tamanho: row.size || 'Não informado',
pendencias: row.issues.map(issue => issueLabels[issue]).join(', '),
vendido: row.quantitySold,
estoque: row.stock
})), `cadastro-para-corte-${dateSuffix}`);
};
const activeRowCount = activeQueue === 'negative_stock'
? visibleNegativeStockRows.length
: activeQueue === 'missing_composition'
? visibleMissingCompositionRows.length
: rows.length;
const totalPages = Math.ceil(activeRowCount / itemsPerPage);
const safeCurrentPage = Math.min(currentPage, totalPages || 1);
const startIndex = (safeCurrentPage - 1) * itemsPerPage;
const paginatedRows = rows.slice(startIndex, startIndex + itemsPerPage);
const paginatedNegativeStockRows = visibleNegativeStockRows.slice(startIndex, startIndex + itemsPerPage);
const paginatedMissingCompositionRows = visibleMissingCompositionRows.slice(startIndex, startIndex + itemsPerPage);
const isRefreshing = isLoading && products.length > 0;
return (
<div className="space-y-6">
<div className="grid grid-cols-1 gap-4 2xl:grid-cols-[minmax(520px,1fr)_auto] 2xl:items-start">
<div>
<h1 className="mb-2 text-2xl font-bold text-zinc-900 dark:text-dark-text">Dados Pendentes</h1>
<p className="font-medium text-zinc-500 dark:text-dark-muted">
Exceções de estoque, composição e cadastro. Elas ficam visíveis aqui, sem parar compra, corte ou produção.
</p>
</div>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center 2xl:justify-end">
<button
type="button"
onClick={exportActiveQueue}
className="inline-flex h-10 items-center justify-center gap-2 rounded-xl border border-dark-border bg-dark-card px-4 text-sm font-bold text-dark-text transition-colors hover:border-brand-primary cursor-pointer"
>
<Download className="h-4 w-4 text-brand-primary" />
Exportar CSV
</button>
<DateRangePicker
dateRange={dateRange}
onChange={(range) => {
setDateRange(range);
setCurrentPage(1);
}}
/>
</div>
</div>
<RefreshStatus isRefreshing={isRefreshing} />
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
{[
{
id: 'negative_stock' as const,
label: 'Saldos negativos',
description: 'Estoque informado abaixo de zero',
count: negativeStockRows.length,
tone: 'critical',
icon: AlertTriangle,
},
{
id: 'missing_composition' as const,
label: 'SKUs sem composição',
description: 'Fora do cálculo de materiais',
count: missingCompositionRows.length,
tone: 'info',
icon: PackageSearch,
},
{
id: 'cutting_data' as const,
label: 'Cadastro para corte',
description: 'Tipo, cor, tamanho, família e rendimento',
count: Array.from(issueCounts.values()).reduce((total, count) => total + count, 0),
tone: 'attention',
icon: Pencil,
},
].map(queue => {
const Icon = queue.icon;
const active = activeQueue === queue.id;
const tone = queue.tone === 'critical'
? active ? 'border-red-400/45 bg-red-500/[0.07]' : 'border-dark-border bg-dark-card hover:border-red-400/35'
: queue.tone === 'info'
? active ? 'border-sky-400/45 bg-sky-500/[0.07]' : 'border-dark-border bg-dark-card hover:border-sky-400/35'
: active ? 'border-amber-400/45 bg-amber-500/[0.07]' : 'border-dark-border bg-dark-card hover:border-amber-400/35';
const iconTone = queue.tone === 'critical' ? 'text-red-300' : queue.tone === 'info' ? 'text-sky-300' : 'text-amber-300';
return (
<button
key={queue.id}
type="button"
onClick={() => {
setActiveQueue(queue.id);
setCurrentPage(1);
setSearchTerm('');
}}
className={`rounded-2xl border p-5 text-left transition-colors cursor-pointer ${tone}`}
>
<div className="flex items-start justify-between gap-3">
<span className="text-xs font-bold uppercase tracking-widest text-dark-muted">{queue.label}</span>
<Icon className={`h-4 w-4 ${iconTone}`} />
</div>
<div className="mt-3 text-3xl font-bold text-dark-text">{formatNumber(queue.count)}</div>
<p className="mt-1 text-xs font-semibold text-dark-muted">{queue.description}</p>
</button>
);
})}
</div>
{activeQueue === 'cutting_data' ? (
<>
<div className="grid grid-cols-1 gap-4 md:grid-cols-3 xl:grid-cols-5">
{issueFilters.filter(option => option.value !== 'all').map(option => {
const count = issueCounts.get(option.value as IssueKey) || 0;
return (
<button
key={option.value}
type="button"
onClick={() => {
setIssueFilter(option.value);
setCurrentPage(1);
}}
className={`rounded-2xl border p-4 text-left transition-colors cursor-pointer ${
issueFilter === option.value
? 'border-brand-primary/45 bg-brand-primary/10'
: 'border-dark-border bg-dark-card hover:border-brand-primary/35'
}`}
title={issueHelp[option.value as IssueKey]}
>
<div className="flex items-center justify-between gap-3">
<span className="text-xs font-bold uppercase tracking-widest text-dark-muted">{option.label}</span>
<AlertTriangle className={count ? 'h-4 w-4 text-amber-300' : 'h-4 w-4 text-emerald-300'} />
</div>
<div className="mt-3 text-2xl font-bold text-dark-text">{formatNumber(count)}</div>
</button>
);
})}
</div>
<div className="grid grid-cols-1 gap-3 rounded-2xl border border-dark-border bg-dark-card p-4 shadow-sm xl:grid-cols-[minmax(360px,1fr)_auto] xl:items-center">
<div className="flex flex-col gap-3 md:flex-row md:items-center">
<div className="relative w-full md:w-96">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-dark-muted" />
<input
type="text"
value={searchTerm}
onChange={(event) => {
setSearchTerm(event.target.value);
setCurrentPage(1);
}}
placeholder="Buscar SKU, produto, cor, tipo..."
className="h-10 w-full rounded-xl border border-dark-border bg-dark-input pl-10 pr-3 text-sm font-semibold text-dark-text outline-none transition-colors placeholder:text-dark-muted focus:border-brand-primary"
/>
</div>
<select
value={issueFilter}
onChange={(event) => {
setIssueFilter(event.target.value as IssueFilter);
setCurrentPage(1);
}}
className="h-10 rounded-xl border border-dark-border bg-dark-input px-3 text-sm font-bold text-dark-text outline-none transition-colors focus:border-brand-primary cursor-pointer"
>
{issueFilters.map(option => (
<option key={option.value} value={option.value}>{option.label}</option>
))}
</select>
</div>
<div className="text-sm font-semibold text-dark-muted">
{formatNumber(rows.length)} SKUs pendentes
</div>
</div>
{isLoading && !products.length ? (
<PlanningIssuesSkeleton />
) : rows.length ? (
<div className="overflow-hidden rounded-2xl border border-dark-border bg-dark-card shadow-sm">
<div className="overflow-x-auto">
<table className="w-full min-w-[1180px] table-fixed text-left text-sm">
<colgroup>
<col className="w-[120px]" />
<col className="w-[380px]" />
<col className="w-[150px]" />
<col className="w-[180px]" />
<col className="w-[140px]" />
<col className="w-[140px]" />
<col className="w-[120px]" />
<col className="w-[120px]" />
</colgroup>
<thead className="border-b border-dark-border bg-dark-header text-dark-muted">
<tr>
<th className="px-6 py-4 text-[10px] font-bold uppercase tracking-wider">SKU</th>
<th className="px-6 py-4 text-[10px] font-bold uppercase tracking-wider">Produto</th>
<th className="px-6 py-4 text-[10px] font-bold uppercase tracking-wider">Tipo</th>
<th className="px-6 py-4 text-[10px] font-bold uppercase tracking-wider">Pendências</th>
<th className="px-6 py-4 text-[10px] font-bold uppercase tracking-wider">Família</th>
<th className="px-6 py-4 text-[10px] font-bold uppercase tracking-wider">Vendido</th>
<th className="px-6 py-4 text-[10px] font-bold uppercase tracking-wider">Receita</th>
<th className="px-4 py-4 text-right text-[10px] font-bold uppercase tracking-wider">Ações</th>
</tr>
</thead>
<tbody className="divide-y divide-dark-border">
{paginatedRows.map(row => (
<tr key={row.id} className="transition-colors hover:bg-dark-input/50">
<td className="px-6 py-2.5 font-mono text-[11px] text-dark-muted">#{row.id}</td>
<td className="max-w-0 px-6 py-2.5">
<div className="truncate font-semibold text-dark-text" title={row.name}>{row.name}</div>
<div className="mt-1 flex min-w-0 items-center gap-2 text-[10px] font-semibold text-dark-muted">
<span className="truncate">Cor: {row.color || '-'}</span>
<span>·</span>
<span className="truncate">Tam.: {row.size || '-'}</span>
{row.hasOverride && <span className="rounded-full border border-brand-primary/25 bg-brand-primary/10 px-2 py-0.5 text-brand-primary">manual</span>}
</div>
</td>
<td className="px-6 py-2.5">
<ProductTypeBadge type={row.productType} />
</td>
<td className="px-6 py-2.5">
<div className="flex flex-wrap gap-1.5">
{row.issues.map(issue => (
<span
key={issue}
className="inline-flex rounded-full border border-amber-400/25 bg-amber-400/10 px-2 py-0.5 text-[10px] font-bold text-amber-300"
title={issueHelp[issue]}
>
{issueLabels[issue]}
</span>
))}
</div>
</td>
<td className="px-6 py-2.5 text-xs font-bold text-dark-text">{row.familyLabel}</td>
<td className="px-6 py-2.5 whitespace-nowrap font-bold text-dark-text">{formatNumber(row.quantitySold)} un.</td>
<td className="px-6 py-2.5 whitespace-nowrap font-bold text-brand-primary">{formatCurrency(row.revenue)}</td>
<td className="px-4 py-2.5 text-right">
<div className="flex justify-end gap-2">
<button
type="button"
onClick={() => setEditingProduct(row)}
className="inline-flex h-8 w-8 items-center justify-center rounded-lg bg-dark-input text-dark-text transition-colors hover:bg-dark-border cursor-pointer"
title={`Editar planejamento do SKU ${row.id}`}
aria-label={`Editar planejamento do SKU ${row.id}`}
>
<Pencil className="h-3.5 w-3.5" />
</button>
<Link
to={`/products/${row.id}`}
className="inline-flex h-8 w-8 items-center justify-center rounded-lg bg-brand-primary/10 text-brand-primary transition-opacity hover:opacity-80 cursor-pointer"
title={`Ver SKU ${row.id}`}
aria-label={`Ver SKU ${row.id}`}
>
<Eye className="h-3.5 w-3.5" />
</Link>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
<PaginationControls
totalItems={rows.length}
currentPage={safeCurrentPage}
totalPages={totalPages}
pageSize={itemsPerPage}
pageSizeOptions={[10, 20, 50, 100]}
itemLabel="SKUs"
pageSizeLabel="por página"
startIndex={startIndex}
endIndex={Math.min(startIndex + itemsPerPage, rows.length)}
onPageChange={setCurrentPage}
onPageSizeChange={(size) => {
setItemsPerPage(size);
setCurrentPage(1);
}}
/>
</div>
) : (
<div className="flex flex-col items-center justify-center rounded-2xl border border-dark-border bg-dark-card px-6 py-16 text-center shadow-sm">
{products.length ? (
<>
<CheckCircle2 className="h-10 w-10 text-emerald-300" />
<p className="mt-4 text-sm font-bold text-dark-text">Nenhum dado pendente neste filtro.</p>
<p className="mt-1 text-sm font-semibold text-dark-muted">Ajuste o filtro ou o período para revisar outros SKUs.</p>
</>
) : (
<>
<PackageSearch className="h-10 w-10 text-dark-muted" />
<p className="mt-4 text-sm font-bold text-dark-text">Sem produtos no período.</p>
<p className="mt-1 text-sm font-semibold text-dark-muted">Altere o intervalo de datas para carregar a fila.</p>
</>
)}
</div>
)}
</>
) : (
<div className="space-y-4">
<div className="grid grid-cols-1 gap-3 rounded-2xl border border-dark-border bg-dark-card p-4 shadow-sm xl:grid-cols-[minmax(360px,1fr)_auto] xl:items-center">
<div className="relative w-full md:w-96">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-dark-muted" />
<input
type="text"
value={searchTerm}
onChange={(event) => {
setSearchTerm(event.target.value);
setCurrentPage(1);
}}
placeholder={activeQueue === 'negative_stock' ? 'Buscar material ou SKU...' : 'Buscar SKU ou produto...'}
className="h-10 w-full rounded-xl border border-dark-border bg-dark-input pl-10 pr-3 text-sm font-semibold text-dark-text outline-none transition-colors placeholder:text-dark-muted focus:border-brand-primary"
/>
</div>
<p className="text-sm font-semibold text-dark-muted">
{formatNumber(activeRowCount)} {activeQueue === 'negative_stock' ? 'materiais para conferir' : 'SKUs fora do cálculo'}
</p>
</div>
{isLoading && !products.length ? (
<PlanningIssuesSkeleton />
) : activeQueue === 'negative_stock' ? (
visibleNegativeStockRows.length ? (
<div className="overflow-hidden rounded-2xl border border-dark-border bg-dark-card shadow-sm">
<div className="flex flex-col gap-1 border-b border-dark-border px-5 py-4 sm:flex-row sm:items-center sm:justify-between">
<div><h2 className="text-base font-bold text-dark-text">Saldos negativos</h2><p className="mt-1 text-sm font-semibold text-dark-muted">O saldo Tiny continua visível para auditoria. Informe uma conferência no Graphs para substituir sua base no planejamento.</p></div>
<span className="w-fit rounded-full border border-red-400/25 bg-red-400/10 px-2.5 py-1 text-xs font-bold text-red-300">{formatNumber(visibleNegativeStockRows.length)} materiais</span>
</div>
<div className="overflow-x-auto">
<table className="w-full min-w-[920px] table-fixed text-left text-sm">
<colgroup>
<col className="w-[44%]" />
<col className="w-[15%]" />
<col className="w-[15%]" />
<col className="w-[18%]" />
<col className="w-[8%]" />
</colgroup>
<thead className="border-b border-dark-border bg-dark-header text-dark-muted">
<tr>
<th className="px-5 py-3 text-[10px] font-bold uppercase tracking-wider">Material</th>
<th className="px-5 py-3 text-right text-[10px] font-bold uppercase tracking-wider">Saldo Tiny</th>
<th className="px-5 py-3 text-right text-[10px] font-bold uppercase tracking-wider">Saldo Graphs</th>
<th className="px-5 py-3 text-center text-[10px] font-bold uppercase tracking-wider">Planejamento</th>
<th className="px-5 py-3 text-right text-[10px] font-bold uppercase tracking-wider">Ações</th>
</tr>
</thead>
<tbody className="divide-y divide-dark-border">
{paginatedNegativeStockRows.map(row => (
<tr key={row.produto_id} className="transition-colors hover:bg-dark-input/50">
<td className="max-w-0 px-5 py-3"><p className="truncate font-bold text-dark-text" title={row.nome}>{row.nome}</p><p className="mt-1 font-mono text-[11px] font-semibold text-dark-muted">#{row.produto_id}</p></td>
<td className="px-5 py-3 text-right"><p className="font-bold text-red-300">{formatNumber(Number(row.saldo), 2)} un.</p><p className="mt-1 text-[11px] font-semibold text-dark-muted">importado do Tiny</p></td>
<td className="px-5 py-3 text-right">
{row.graphs_balance === null || row.graphs_balance === undefined ? (
<p className="text-xs font-bold text-dark-muted">Não informado</p>
) : (
<><p className="font-bold text-brand-primary">{formatNumber(Number(row.graphs_balance), 2)} un.</p><p className="mt-1 text-[11px] font-semibold text-dark-muted">conferido no Graphs</p></>
)}
</td>
<td className="px-5 py-3 text-center"><span className={`inline-flex rounded-full border px-2.5 py-1 text-xs font-bold ${Number(row.planning_balance ?? row.saldo) > 0 ? 'border-emerald-400/25 bg-emerald-400/10 text-emerald-300' : 'border-dark-border bg-dark-input text-dark-muted'}`}>Usa {formatNumber(Math.max(0, Number(row.planning_balance ?? row.saldo)), 2)} disponível</span></td>
<td className="px-5 py-3 text-right">
<div className="flex justify-end gap-2">
<button type="button" onClick={() => setEditingStock(row)} title={`Conferir saldo de ${row.nome}`} aria-label={`Conferir saldo de ${row.nome}`} className="inline-flex h-8 w-8 items-center justify-center rounded-lg border border-dark-border bg-dark-input text-dark-text transition-colors hover:border-brand-primary hover:text-brand-primary cursor-pointer"><Pencil className="h-3.5 w-3.5" /></button>
<Link to="/supplies/inventory?tab=stock" title={`Ver ${row.nome} no estoque`} aria-label={`Ver ${row.nome} no estoque`} className="inline-flex h-8 w-8 items-center justify-center rounded-lg border border-dark-border bg-dark-input text-brand-primary transition-colors hover:border-brand-primary cursor-pointer"><Eye className="h-3.5 w-3.5" /></Link>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
<PaginationControls
totalItems={visibleNegativeStockRows.length}
currentPage={safeCurrentPage}
totalPages={totalPages}
pageSize={itemsPerPage}
pageSizeOptions={[10, 20, 50, 100]}
itemLabel="materiais"
pageSizeLabel="por página"
startIndex={startIndex}
endIndex={Math.min(startIndex + itemsPerPage, visibleNegativeStockRows.length)}
onPageChange={setCurrentPage}
onPageSizeChange={(size) => { setItemsPerPage(size); setCurrentPage(1); }}
/>
</div>
) : (
<div className="flex flex-col items-center justify-center rounded-2xl border border-dark-border bg-dark-card px-6 py-16 text-center shadow-sm">
<CheckCircle2 className="h-10 w-10 text-emerald-300" />
<p className="mt-4 text-sm font-bold text-dark-text">Nenhum saldo negativo neste filtro.</p>
</div>
)
) : visibleMissingCompositionRows.length ? (
<div className="overflow-hidden rounded-2xl border border-dark-border bg-dark-card shadow-sm">
<div className="flex flex-col gap-1 border-b border-dark-border px-5 py-4 sm:flex-row sm:items-center sm:justify-between">
<div><h2 className="text-base font-bold text-dark-text">SKUs sem composição</h2><p className="mt-1 text-sm font-semibold text-dark-muted">Continuam operando, mas não entram na necessidade de materiais até receberem uma composição.</p></div>
<span className="w-fit rounded-full border border-sky-400/25 bg-sky-400/10 px-2.5 py-1 text-xs font-bold text-sky-300">{formatNumber(visibleMissingCompositionRows.length)} SKUs</span>
</div>
<div className="overflow-x-auto">
<table className="w-full min-w-[760px] text-left text-sm">
<thead className="border-b border-dark-border bg-dark-header text-dark-muted">
<tr>
<th className="px-5 py-3 text-[10px] font-bold uppercase tracking-wider">Produto</th>
<th className="px-5 py-3 text-right text-[10px] font-bold uppercase tracking-wider">Vendido</th>
<th className="px-5 py-3 text-right text-[10px] font-bold uppercase tracking-wider">Estoque</th>
<th className="px-5 py-3 text-right text-[10px] font-bold uppercase tracking-wider">Cortar agora</th>
<th className="px-5 py-3 text-right text-[10px] font-bold uppercase tracking-wider">Ações</th>
</tr>
</thead>
<tbody className="divide-y divide-dark-border">
{paginatedMissingCompositionRows.map(row => (
<tr key={row.productId} className="transition-colors hover:bg-dark-input/50">
<td className="max-w-0 px-5 py-3"><p className="truncate font-bold text-dark-text" title={row.name}>{row.name}</p><p className="mt-1 font-mono text-[11px] font-semibold text-dark-muted">#{row.productId}</p></td>
<td className="px-5 py-3 text-right font-bold text-dark-text">{formatNumber(row.quantitySold)} un.</td>
<td className="px-5 py-3 text-right font-bold text-dark-text">{formatNumber(row.stockQuantity)} un.</td>
<td className="px-5 py-3 text-right font-bold text-amber-300">{formatNumber(row.suggestedQuantity)} un.</td>
<td className="px-5 py-3 text-right">
<div className="flex justify-end gap-2">
<Link to={`/products/${row.productId}`} className="inline-flex h-8 w-8 items-center justify-center rounded-lg border border-dark-border bg-dark-input text-dark-text transition-colors hover:border-brand-primary cursor-pointer" title="Cadastrar composição" aria-label={`Cadastrar composição de ${row.name}`}><Pencil className="h-3.5 w-3.5" /></Link>
<Link to={`/products/${row.productId}`} className="inline-flex h-8 w-8 items-center justify-center rounded-lg bg-brand-primary/10 text-brand-primary transition-opacity hover:opacity-80 cursor-pointer" title="Ver produto" aria-label={`Ver produto ${row.name}`}><Eye className="h-3.5 w-3.5" /></Link>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
<PaginationControls
totalItems={visibleMissingCompositionRows.length}
currentPage={safeCurrentPage}
totalPages={totalPages}
pageSize={itemsPerPage}
pageSizeOptions={[10, 20, 50, 100]}
itemLabel="SKUs"
pageSizeLabel="por página"
startIndex={startIndex}
endIndex={Math.min(startIndex + itemsPerPage, visibleMissingCompositionRows.length)}
onPageChange={setCurrentPage}
onPageSizeChange={(size) => { setItemsPerPage(size); setCurrentPage(1); }}
/>
</div>
) : (
<div className="flex flex-col items-center justify-center rounded-2xl border border-dark-border bg-dark-card px-6 py-16 text-center shadow-sm">
<CheckCircle2 className="h-10 w-10 text-emerald-300" />
<p className="mt-4 text-sm font-bold text-dark-text">Nenhum SKU sem composição neste filtro.</p>
</div>
)}
</div>
)}
{editingProduct && (
<SkuPlanningModal
product={editingProduct}
override={settings.productOverrides[editingProduct.id]}
isSaving={isSaving}
onClose={() => setEditingProduct(null)}
onSave={(override) => saveProductOverride(editingProduct.id, override)}
/>
)}
{editingStock && (
<StockGraphsBalanceModal
stock={editingStock}
onClose={() => setEditingStock(null)}
onSaved={saveGraphsBalance}
/>
)}
</div>
);
};
export default PlanningIssues;

View File

@@ -1,316 +1,76 @@
import { useEffect, useState } from 'react';
import { useMemo } from 'react';
import { useParams, Link, useOutletContext } from 'react-router-dom';
import { Package, DollarSign, Pencil, Plus, ReceiptText, Trash2, Warehouse, X } from 'lucide-react';
import { AreaChart, Area, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
import BackButton from '../components/BackButton';
import { ArrowLeft, Package, DollarSign } from 'lucide-react';
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
import DateRangePicker from '../components/DateRangePicker';
import SkuPlanningModal from '../components/SkuPlanningModal';
import ProductTypeBadge from '../components/ProductTypeBadge';
import RefreshStatus from '../components/RefreshStatus';
import PaginationControls from '../components/PaginationControls';
import type { CutProductOverride, CuttingSettings, DateRange, ProductComposition, ProductDetailsAnalytics } from '../types';
import { deleteManualProductComposition, fetchCuttingSettings, fetchProductComposition, fetchProductDetailsAnalytics, saveCuttingSettings, saveManualProductComposition } from '../dataService';
import { parseProductName } from '../productParsing';
import { formatColorLabel } from '../displayFormatters';
import { averageRecentValues, formatDateBucketLabel, formatDateBucketLongLabel, getAutoDateBucket, getMovingAverageWindow, getRangeDayCount, getDateBucketKey, type DateBucket } from '../chartUtils';
import { getProductTypeConfig, resolveProductType } from '../productClassification';
import { getPlanningStock } from '../planningStock';
import type { OrderData, DateRange } from '../types';
import { parseOrderDate } from '../dataService';
const CHART_GRID_COLOR = 'var(--chart-grid)';
const CHART_AXIS_COLOR = 'var(--chart-axis)';
const CHART_CURSOR_COLOR = 'var(--chart-cursor)';
const CHART_DETAIL_BAR_COLOR = 'var(--chart-detail-bar)';
const VARIANT_BAR_COLOR = '#52DFA0';
type ProductChartMetric = 'quantity' | 'revenue' | 'ticket';
type CompositionFormComponent = {
componentTinyId: string;
componentSku: string;
componentName: string;
quantityPerUnit: string;
unit: string;
};
const emptyCompositionComponent = (): CompositionFormComponent => ({
componentTinyId: '', componentSku: '', componentName: '', quantityPerUnit: '', unit: 'KG'
});
type ProductMetricChartPoint = ProductDetailsAnalytics['chartData'][number] & {
selectedValue: number;
movingAverage?: number;
};
const formatDateKey = (date: Date) => {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
};
type CustomTooltipProps = {
active?: boolean;
payload?: Array<{
value: number;
dataKey?: string;
payload?: ProductMetricChartPoint;
}>;
label?: string;
metric: ProductChartMetric;
formatCurrency: (value: number) => string;
formatNumber: (value: number) => string;
isHourly: boolean;
dateBucket: DateBucket;
};
const CustomTooltip = ({ active, payload, label, metric, formatCurrency, formatNumber, isHourly, dateBucket }: CustomTooltipProps) => {
const CustomTooltip = ({ active, payload, label }: any) => {
if (active && payload && payload.length) {
const primaryPayload = payload.find(item => item.dataKey === 'selectedValue') || payload[0];
const point = primaryPayload.payload;
const value = primaryPayload.value;
const displayValue = metric === 'quantity' ? `${formatNumber(value)} un.` : formatCurrency(value);
const displayLabel = isHourly ? String(label || '') : formatDateBucketLongLabel(String(label || ''), dateBucket);
return (
<div
className="rounded-xl border p-3 shadow-lg"
style={{ backgroundColor: 'var(--chart-tooltip-bg)', borderColor: 'var(--chart-tooltip-border)' }}
>
<p className="font-bold mb-1" style={{ color: CHART_DETAIL_BAR_COLOR }}>{displayLabel}</p>
<p className="m-0 font-semibold" style={{ color: 'var(--chart-tooltip-text)' }}>{displayValue}</p>
{point && (
<div className="mt-2 space-y-1 text-xs" style={{ color: 'var(--chart-axis)' }}>
<p className="m-0">Unidades: {formatNumber(point.quantitySold ?? point.value ?? 0)}</p>
<p className="m-0">Receita: {formatCurrency(point.revenue ?? 0)}</p>
<p className="m-0">Pedidos: {formatNumber(point.orderCount ?? 0)}</p>
</div>
)}
<div className="bg-[#141414] p-3 rounded-xl shadow-lg border-none">
<p className="font-bold mb-1" style={{ color: '#9ECAE1' }}>{label}</p>
<p className="text-[#ededed] m-0">Vendas: {payload[0].value}</p>
</div>
);
}
return null;
};
const ProductDetailsSkeleton = () => (
<div className="space-y-6" aria-label="Carregando produto">
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
<div className="flex flex-col gap-4">
<div className="skeleton h-4 w-20" />
<div className="flex items-center gap-4">
<div className="skeleton h-16 w-16 rounded-2xl" />
<div>
<div className="skeleton h-3 w-24" />
<div className="skeleton mt-3 h-7 w-80 max-w-[70vw]" />
</div>
</div>
</div>
<div className="skeleton h-10 w-64" />
</div>
<div className="grid grid-cols-1 md:grid-cols-4 gap-6">
{[0, 1, 2, 3].map(item => (
<div key={`product-details-kpi-skeleton-${item}`} className="bg-dark-card p-6 rounded-2xl border border-dark-border shadow-sm">
<div className="flex justify-between gap-6">
<div className="w-full">
<div className="skeleton h-3 w-36" />
<div className="skeleton mt-3 h-8 w-32" />
</div>
<div className="skeleton h-12 w-12 shrink-0 rounded-xl" />
</div>
</div>
))}
</div>
<div className="bg-white dark:bg-dark-card border border-zinc-200 dark:border-dark-border rounded-2xl p-6 shadow-sm">
<div className="skeleton h-5 w-56" />
<div className="mt-8 skeleton h-[400px] w-full" />
</div>
<div className="bg-white dark:bg-dark-card border border-zinc-200 dark:border-dark-border rounded-2xl p-6 shadow-sm">
<div className="skeleton h-5 w-44" />
<div className="mt-5 space-y-3">
{[0, 1, 2, 3].map(item => (
<div key={`product-variant-skeleton-${item}`} className="skeleton h-12 w-full" />
))}
</div>
</div>
</div>
);
const ProductDetails = () => {
const { id } = useParams<{ id: string }>();
const { dateRange, setDateRange } = useOutletContext<{
const { dateRange, setDateRange, ordersData } = useOutletContext<{
dateRange: DateRange,
setDateRange: (range: DateRange) => void
setDateRange: (range: DateRange) => void,
ordersData: OrderData[],
refreshInterval: number,
setRefreshInterval: (interval: number) => void,
loadData: (showLoading?: boolean) => void
}>();
const [details, setDetails] = useState<ProductDetailsAnalytics | null>(null);
const [composition, setComposition] = useState<ProductComposition | null>(null);
const [isCompositionLoading, setIsCompositionLoading] = useState(true);
const [isLoading, setIsLoading] = useState(true);
const [chartMetric, setChartMetric] = useState<ProductChartMetric>('quantity');
const [selectedProductBucket, setSelectedProductBucket] = useState<string | null>(null);
const [planningSettings, setPlanningSettings] = useState<CuttingSettings>({ familyYields: {}, productOverrides: {} });
const [isPlanningModalOpen, setIsPlanningModalOpen] = useState(false);
const [isSavingPlanning, setIsSavingPlanning] = useState(false);
const [variantCurrentPage, setVariantCurrentPage] = useState(1);
const [variantItemsPerPage, setVariantItemsPerPage] = useState(5);
const [isCompositionModalOpen, setIsCompositionModalOpen] = useState(false);
const [compositionForm, setCompositionForm] = useState<CompositionFormComponent[]>([emptyCompositionComponent()]);
const [isSavingComposition, setIsSavingComposition] = useState(false);
const [compositionError, setCompositionError] = useState('');
useEffect(() => {
const scrollContainer = document.querySelector<HTMLElement>('.app-content');
if (scrollContainer) {
scrollContainer.scrollTo({ top: 0, behavior: 'auto' });
} else {
window.scrollTo({ top: 0, behavior: 'auto' });
}
}, [id]);
const { productInfo, chartData, totalSold, totalRevenue } = useMemo(() => {
const orders = ordersData.filter(order => order.ID_Produto === id);
useEffect(() => {
let isMounted = true;
if (orders.length === 0) return { productInfo: null, chartData: [], totalSold: 0, totalRevenue: 0 };
const loadPlanningSettings = async () => {
const settings = await fetchCuttingSettings();
if (isMounted) setPlanningSettings(settings);
const info = {
id: orders[0].ID_Produto,
name: orders[0].Descricao_Produto.split(' TAMANHO')[0],
price: orders[0].Valor_Unitario
};
void loadPlanningSettings();
const salesByDate: Record<string, number> = {};
let sold = 0;
let revenue = 0;
return () => {
isMounted = false;
};
}, []);
orders.forEach(order => {
const orderDate = parseOrderDate(order.Data_Pedido);
useEffect(() => {
let isMounted = true;
const loadProductDetails = async () => {
if (!id) {
if (isMounted) {
setDetails(null);
setComposition(null);
setIsLoading(false);
setIsCompositionLoading(false);
}
return;
if (orderDate >= dateRange.start && orderDate <= dateRange.end) { const dateStr = order.Data_Pedido;
salesByDate[dateStr] = (salesByDate[dateStr] || 0) + order.Quantidade;
sold += order.Quantidade;
revenue += (order.Quantidade * order.Valor_Unitario);
}
});
setIsLoading(true);
setIsCompositionLoading(true);
const [productDetails, productComposition] = await Promise.all([
fetchProductDetailsAnalytics(id, dateRange),
fetchProductComposition(id)
]);
const chart = Object.keys(salesByDate).map(date => ({
date,
value: salesByDate[date]
})).sort((a, b) => {
const [da, ma, ya] = a.date.split('-').map(Number);
const [db, mb, yb] = b.date.split('-').map(Number);
return new Date(ya, ma - 1, da).getTime() - new Date(yb, mb - 1, db).getTime();
});
if (isMounted) {
setDetails(productDetails);
setComposition(productComposition);
setVariantCurrentPage(1);
setIsLoading(false);
setIsCompositionLoading(false);
}
};
void loadProductDetails();
return () => {
isMounted = false;
};
}, [dateRange, id]);
return { productInfo: info, chartData: chart, totalSold: sold, totalRevenue: revenue };
}, [id, dateRange, ordersData]);
const formatCurrency = (value: number) => {
return new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(value);
};
const formatNumber = (value: number) => {
return new Intl.NumberFormat('pt-BR').format(value);
};
const saveProductOverride = async (productId: string, override: CutProductOverride | null) => {
const productOverrides = { ...planningSettings.productOverrides };
if (override) {
productOverrides[productId] = override;
} else {
delete productOverrides[productId];
}
const nextSettings = { ...planningSettings, productOverrides };
setIsSavingPlanning(true);
try {
const savedSettings = await saveCuttingSettings(nextSettings);
setPlanningSettings(savedSettings);
setIsPlanningModalOpen(false);
} finally {
setIsSavingPlanning(false);
}
};
const openCompositionEditor = () => {
setCompositionForm(composition?.components.length
? composition.components.map(component => ({
componentTinyId: component.componentTinyId,
componentSku: component.componentSku,
componentName: component.componentName,
quantityPerUnit: String(component.quantityPerUnit || ''),
unit: component.unit || 'KG'
}))
: [emptyCompositionComponent()]);
setCompositionError('');
setIsCompositionModalOpen(true);
};
const saveComposition = async () => {
if (!id || !details?.productInfo) return;
const components = compositionForm.map(component => ({
...component,
componentName: component.componentName.trim(),
componentSku: component.componentSku.trim(),
componentTinyId: component.componentTinyId.trim(),
unit: component.unit.trim().toUpperCase(),
quantityPerUnit: Number(component.quantityPerUnit.replace(',', '.'))
}));
if (!components.length || components.some(component => !component.componentName || !component.unit || !Number.isFinite(component.quantityPerUnit) || component.quantityPerUnit <= 0)) {
setCompositionError('Informe nome, quantidade positiva e unidade para todos os insumos.');
return;
}
setIsSavingComposition(true);
setCompositionError('');
try {
const savedComposition = await saveManualProductComposition(id, {
productSku: composition?.finishedProductSku || '',
productDescription: details.productInfo.name,
productUnit: composition?.finishedProductUnit || 'UN',
components
});
setComposition(savedComposition);
setIsCompositionModalOpen(false);
} catch (saveError) {
setCompositionError(saveError instanceof Error ? saveError.message : 'Não foi possível salvar a composição.');
} finally {
setIsSavingComposition(false);
}
};
const removeManualComposition = async () => {
if (!id || !window.confirm('Excluir a composição manual? A composição sincronizada da Olist, se existir, voltará a ser usada.')) return;
setIsSavingComposition(true);
try {
await deleteManualProductComposition(id);
setComposition(await fetchProductComposition(id));
} catch (deleteError) {
setCompositionError(deleteError instanceof Error ? deleteError.message : 'Não foi possível excluir a composição manual.');
} finally {
setIsSavingComposition(false);
}
};
if (isLoading && !details) {
return <ProductDetailsSkeleton />;
}
if (!details?.productInfo) {
if (!productInfo) {
return (
<div className="text-center py-12">
<p className="text-zinc-500 dark:text-dark-muted font-medium">Produto não encontrado.</p>
@@ -319,115 +79,14 @@ const ProductDetails = () => {
);
}
const { productInfo, chartData, totalSold, totalRevenue, totalOrders = 0, averageTicket = 0, variantBreakdown = [] } = details;
const productType = resolveProductType(productInfo.name, planningSettings.productOverrides[productInfo.id]);
const productTypeConfig = getProductTypeConfig(productType);
const isRefreshing = isLoading && Boolean(details);
const isSingleDayRange = formatDateKey(dateRange.start) === formatDateKey(dateRange.end);
const isHourlyChart = isSingleDayRange && chartData.some(point => /h$|:/.test(point.date));
const dateBucket = isHourlyChart ? 'day' : getAutoDateBucket(dateRange);
const periodDays = getRangeDayCount(dateRange);
const dailyAverageSold = totalSold / periodDays;
const projectedStockDays = dailyAverageSold > 0 ? getPlanningStock(productInfo.stock) / dailyAverageSold : null;
const stockActionLabel = projectedStockDays === null
? 'Sem venda no período'
: projectedStockDays <= 7
? 'Reposição crítica'
: projectedStockDays <= 21
? 'Planejar reposição'
: 'Estoque confortável';
const metricConfig = {
quantity: {
label: 'Unidades',
title: `Volume por ${isHourlyChart ? 'Horário' : 'Data'}`,
subtitle: 'Quantidade vendida no período selecionado.',
tickFormatter: (value: number) => formatNumber(value)
},
revenue: {
label: 'Receita',
title: `Receita por ${isHourlyChart ? 'Horário' : 'Data'}`,
subtitle: 'Faturamento do produto no período selecionado.',
tickFormatter: (value: number) => value >= 1000 ? `${formatNumber(value / 1000)}k` : formatCurrency(value)
},
ticket: {
label: 'Ticket médio',
title: `Ticket médio por ${isHourlyChart ? 'Horário' : 'Data'}`,
subtitle: 'Receita média por pedido neste produto.',
tickFormatter: (value: number) => value >= 1000 ? `${formatNumber(value / 1000)}k` : formatCurrency(value)
}
} satisfies Record<ProductChartMetric, {
label: string;
title: string;
subtitle: string;
tickFormatter: (value: number) => string;
}>;
const selectedMetric = metricConfig[chartMetric];
const metricChartData = (() => {
const bucketMap = new Map<string, ProductMetricChartPoint>();
chartData.forEach(point => {
const sourceKey = isHourlyChart ? point.date : getDateBucketKey(point.date, dateBucket);
const current = bucketMap.get(sourceKey) || {
date: sourceKey,
value: 0,
quantitySold: 0,
revenue: 0,
orderCount: 0,
averageTicket: 0,
selectedValue: 0
};
const quantity = point.quantitySold ?? point.value ?? 0;
const revenue = point.revenue ?? 0;
const orderCount = point.orderCount ?? 0;
current.value = (current.value || 0) + quantity;
current.quantitySold = (current.quantitySold || 0) + quantity;
current.revenue = (current.revenue || 0) + revenue;
current.orderCount = (current.orderCount || 0) + orderCount;
current.averageTicket = current.orderCount ? current.revenue / current.orderCount : 0;
bucketMap.set(sourceKey, current);
});
const rows = [...bucketMap.values()]
.filter(point => (point.quantitySold || point.value || point.revenue || point.orderCount))
.sort((a, b) => a.date.localeCompare(b.date))
.map(point => ({
...point,
selectedValue: chartMetric === 'quantity'
? (point.quantitySold ?? point.value)
: chartMetric === 'revenue'
? (point.revenue ?? 0)
: (point.averageTicket ?? 0)
}));
const movingAverageWindow = isHourlyChart ? 0 : getMovingAverageWindow(dateBucket);
if (movingAverageWindow > 1) {
const values = rows.map(point => point.selectedValue);
rows.forEach((point, index) => {
point.movingAverage = averageRecentValues(values, index, movingAverageWindow);
});
}
return rows;
})();
const selectedProductPoint = selectedProductBucket
? metricChartData.find(point => point.date === selectedProductBucket)
: null;
const selectedProductBucketLabel = selectedProductPoint
? isHourlyChart
? selectedProductPoint.date
: formatDateBucketLongLabel(selectedProductPoint.date, dateBucket)
: '';
const maxVariantQuantity = Math.max(...variantBreakdown.map(variant => variant.quantitySold), 0);
const variantTotalPages = Math.ceil(variantBreakdown.length / variantItemsPerPage);
const safeVariantCurrentPage = Math.min(variantCurrentPage, variantTotalPages || 1);
const variantStartIndex = (safeVariantCurrentPage - 1) * variantItemsPerPage;
const paginatedVariantBreakdown = variantBreakdown.slice(variantStartIndex, variantStartIndex + variantItemsPerPage);
return (
<div className="space-y-6">
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
<div className="flex flex-col gap-4">
<BackButton fallbackTo="/products" />
<Link to="/products" className="inline-flex items-center text-sm font-bold text-zinc-400 dark:text-dark-muted hover:text-zinc-900 dark:hover:text-dark-text transition-colors w-fit">
<ArrowLeft className="w-4 h-4 mr-2" />
Voltar
</Link>
<div className="flex items-center gap-4">
<div className="w-16 h-16 rounded-2xl bg-white dark:bg-dark-card border border-zinc-200 dark:border-dark-border flex items-center justify-center shadow-sm text-brand-primary">
@@ -436,19 +95,6 @@ const ProductDetails = () => {
<div>
<div className="text-[10px] font-bold text-zinc-400 dark:text-dark-muted uppercase tracking-widest">ID: #{productInfo.id}</div>
<h1 className="text-2xl font-bold text-zinc-900 dark:text-dark-text">{productInfo.name}</h1>
<div className="mt-2 flex flex-wrap items-center gap-2">
<ProductTypeBadge type={productType} />
<span className="text-xs font-semibold text-dark-muted">{productTypeConfig.description}</span>
<button
type="button"
onClick={() => setIsPlanningModalOpen(true)}
className="inline-flex h-7 w-7 items-center justify-center rounded-lg border border-dark-border bg-dark-input text-dark-muted transition-colors hover:border-brand-primary hover:text-brand-primary cursor-pointer"
title={`Editar planejamento do SKU ${productInfo.id}`}
aria-label={`Editar planejamento do SKU ${productInfo.id}`}
>
<Pencil className="h-3.5 w-3.5" />
</button>
</div>
</div>
</div>
</div>
@@ -458,15 +104,11 @@ const ProductDetails = () => {
onChange={setDateRange}
/> </div>
<RefreshStatus isRefreshing={isRefreshing} />
<div className={isRefreshing ? 'refreshing-content space-y-6' : 'space-y-6'} aria-busy={isRefreshing}>
<div className="grid grid-cols-1 gap-4 md:grid-cols-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="bg-dark-card p-6 rounded-2xl border border-dark-border flex items-center justify-between shadow-sm">
<div>
<p className="text-xs font-bold text-dark-muted uppercase tracking-widest mb-1">Unidades Vendidas</p>
<p className="text-3xl font-bold text-dark-text">{formatNumber(totalSold)}</p>
<p className="mt-1 text-xs font-semibold text-dark-muted">{formatNumber(dailyAverageSold)} un./dia</p>
<p className="text-3xl font-bold text-dark-text">{totalSold}</p>
</div>
<div className="p-3 bg-brand-primary/10 rounded-xl text-brand-primary">
<Package size={24} />
@@ -481,347 +123,28 @@ const ProductDetails = () => {
<DollarSign size={24} />
</div>
</div>
<div className="bg-dark-card p-6 rounded-2xl border border-dark-border flex items-center justify-between shadow-sm">
<div>
<p className="text-xs font-bold text-dark-muted uppercase tracking-widest mb-1">Ticket Médio</p>
<p className="text-3xl font-bold text-dark-text">{formatCurrency(averageTicket)}</p>
</div>
<div className="p-3 bg-sky-500/10 rounded-xl text-sky-500">
<ReceiptText size={24} />
</div>
</div>
<div className="bg-dark-card p-6 rounded-2xl border border-dark-border flex items-center justify-between shadow-sm">
<div>
<p className="text-xs font-bold text-dark-muted uppercase tracking-widest mb-1">Estoque</p>
<p className="text-3xl font-bold text-dark-text">{formatNumber(productInfo.stock)}</p>
<p className="mt-1 text-xs font-semibold text-dark-muted">
{projectedStockDays === null ? stockActionLabel : `${formatNumber(projectedStockDays)} dias · ${stockActionLabel}`}
</p>
</div>
<div className="p-3 bg-purple-500/10 rounded-xl text-purple-400">
<Warehouse size={24} />
</div>
</div>
</div>
<div className="bg-white dark:bg-dark-card border border-zinc-200 dark:border-dark-border rounded-2xl p-6 shadow-sm">
<div className="mb-8 flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
<div>
<h3 className="text-lg font-bold text-zinc-900 dark:text-dark-text">{selectedMetric.title}</h3>
<p className="mt-1 text-sm font-medium text-zinc-500 dark:text-dark-muted">
{isHourlyChart
? selectedMetric.subtitle
: dateBucket === 'day'
? `${selectedMetric.subtitle} Média móvel de 7 dias.`
: dateBucket === 'week'
? 'Valores agrupados por semana com média móvel de 4 semanas.'
: 'Valores agrupados por mês com média móvel de 3 meses.'}
</p>
</div>
<div className="flex w-fit rounded-xl border border-dark-border bg-dark-input p-1">
{(Object.keys(metricConfig) as ProductChartMetric[]).map(metric => (
<button
key={metric}
type="button"
onClick={() => setChartMetric(metric)}
className={`cursor-pointer rounded-lg px-3 py-1.5 text-xs font-bold transition-colors ${
chartMetric === metric
? 'bg-dark-card text-dark-text shadow-sm'
: 'text-dark-muted hover:text-dark-text'
}`}
>
{metricConfig[metric].label}
</button>
))}
</div>
</div>
{metricChartData.length === 0 ? (
<div className="flex h-[360px] items-center justify-center text-sm font-semibold text-zinc-500 dark:text-dark-muted">
Nenhuma venda no período selecionado.
</div>
) : (
<h3 className="text-lg font-bold mb-8 text-zinc-900 dark:text-dark-text">Volume de Vendas por Data</h3>
<div className="h-[400px] w-full">
<ResponsiveContainer width="100%" height="100%">
<AreaChart
data={metricChartData}
margin={{ top: 5, right: 30, left: 20, bottom: 28 }}
onClick={(event) => {
if (event?.activeLabel) setSelectedProductBucket(String(event.activeLabel));
}}
>
<defs>
<linearGradient id="productVolumeGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor={CHART_DETAIL_BAR_COLOR} stopOpacity={0.38} />
<stop offset="95%" stopColor={CHART_DETAIL_BAR_COLOR} stopOpacity={0.04} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke={CHART_GRID_COLOR} vertical={false} />
<BarChart data={chartData} margin={{ top: 5, right: 30, left: 20, bottom: 80 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#222222" vertical={false} />
<XAxis
dataKey="date" stroke={CHART_AXIS_COLOR} fontSize={10} tickLine={false} axisLine={false}
minTickGap={18}
tickFormatter={(value) => (
isHourlyChart ? String(value) : formatDateBucketLabel(String(value), dateBucket)
)}
dataKey="date" stroke="#888888" fontSize={10} tickLine={false} axisLine={false}
interval={0}
angle={-45}
textAnchor="end"
height={80}
/>
<YAxis stroke={CHART_AXIS_COLOR} fontSize={12} tickLine={false} axisLine={false} tickFormatter={(value) => selectedMetric.tickFormatter(Number(value))} />
<Tooltip content={<CustomTooltip metric={chartMetric} formatCurrency={formatCurrency} formatNumber={formatNumber} isHourly={isHourlyChart} dateBucket={dateBucket} />} cursor={{ fill: CHART_CURSOR_COLOR }} />
<Area
type="monotone"
dataKey="selectedValue"
stroke={CHART_DETAIL_BAR_COLOR}
strokeWidth={2.25}
fill="url(#productVolumeGradient)"
dot={{ r: 3, strokeWidth: 2, fill: 'var(--color-dark-card)', stroke: CHART_DETAIL_BAR_COLOR }}
activeDot={{ r: 5, strokeWidth: 2, fill: CHART_DETAIL_BAR_COLOR, stroke: 'var(--color-dark-card)' }}
/>
{!isHourlyChart && metricChartData.some(point => point.movingAverage !== undefined) && (
<Line
type="monotone"
dataKey="movingAverage"
name="Média móvel"
stroke="var(--chart-label)"
strokeWidth={2.5}
strokeDasharray="6 5"
dot={false}
activeDot={false}
/>
)}
</AreaChart>
<YAxis stroke="#888888" fontSize={12} tickLine={false} axisLine={false} />
<Tooltip content={<CustomTooltip />} cursor={{ fill: '#222222' }} />
<Bar dataKey="value" fill="#9ECAE1" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
)}
{selectedProductPoint && (
<div className="mt-4 rounded-xl border border-dark-border bg-dark-input/45 p-4">
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
<div>
<h4 className="text-sm font-bold text-dark-text">{selectedProductBucketLabel}</h4>
<p className="mt-1 text-xs font-semibold text-dark-muted">Detalhe do ponto selecionado.</p>
</div>
<button
type="button"
onClick={() => setSelectedProductBucket(null)}
className="h-8 rounded-lg border border-dark-border bg-dark-card px-3 text-xs font-bold text-dark-muted transition-colors hover:text-dark-text"
>
Limpar
</button>
</div>
<div className="mt-4 grid gap-3 md:grid-cols-5">
<div>
<div className="text-[10px] font-bold uppercase tracking-widest text-dark-muted">Unidades</div>
<div className="mt-1 text-sm font-bold text-dark-text">{formatNumber(selectedProductPoint.quantitySold ?? selectedProductPoint.value ?? 0)}</div>
</div>
<div>
<div className="text-[10px] font-bold uppercase tracking-widest text-dark-muted">Receita</div>
<div className="mt-1 text-sm font-bold text-dark-text">{formatCurrency(selectedProductPoint.revenue ?? 0)}</div>
</div>
<div>
<div className="text-[10px] font-bold uppercase tracking-widest text-dark-muted">Pedidos</div>
<div className="mt-1 text-sm font-bold text-dark-text">{formatNumber(selectedProductPoint.orderCount ?? 0)}</div>
</div>
<div>
<div className="text-[10px] font-bold uppercase tracking-widest text-dark-muted">Ticket</div>
<div className="mt-1 text-sm font-bold text-dark-text">{formatCurrency(selectedProductPoint.averageTicket ?? 0)}</div>
</div>
<div>
<div className="text-[10px] font-bold uppercase tracking-widest text-dark-muted">Cobertura</div>
<div className="mt-1 text-sm font-bold text-dark-text">{projectedStockDays === null ? '-' : `${formatNumber(projectedStockDays)} dias`}</div>
</div>
</div>
</div>
)}
</div>
<section className="bg-white dark:bg-dark-card border border-zinc-200 dark:border-dark-border rounded-2xl p-6 shadow-sm">
<div className="mb-5 flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div>
<h3 className="text-lg font-bold text-zinc-900 dark:text-dark-text">Composição</h3>
{composition ? (
<p className="mt-1 text-sm font-medium text-zinc-500 dark:text-dark-muted">
Para produzir 1 UN de {composition.finishedProductSku || productInfo.id}{composition.source === 'manual' ? ' · Cadastro manual' : ''}
</p>
) : <p className="mt-1 text-sm font-medium text-zinc-500 dark:text-dark-muted">Cadastre os insumos usados para produzir este produto.</p>}
</div>
<div className="flex items-center gap-2">
{composition?.source === 'manual' && (
<button type="button" onClick={() => void removeManualComposition()} disabled={isSavingComposition} title="Excluir composição manual" aria-label="Excluir composição manual" className="inline-flex h-9 w-9 items-center justify-center rounded-lg border border-red-400/30 text-red-300 transition-colors hover:bg-red-400/10 disabled:opacity-50 cursor-pointer"><Trash2 className="h-4 w-4" /></button>
)}
<button type="button" onClick={openCompositionEditor} title={composition ? 'Editar composição' : 'Adicionar composição'} className="inline-flex h-9 items-center gap-2 rounded-lg border border-dark-border px-3 text-xs font-bold text-dark-text transition-colors hover:border-brand-primary cursor-pointer">{composition ? <Pencil className="h-4 w-4 text-brand-primary" /> : <Plus className="h-4 w-4 text-brand-primary" />} {composition ? 'Editar' : 'Adicionar'}</button>
</div>
</div>
{isCompositionLoading ? (
<div className="flex h-24 items-center text-sm font-semibold text-zinc-500 dark:text-dark-muted">Carregando composição…</div>
) : !composition ? (
<div className="flex h-24 items-center justify-center rounded-xl border border-dashed border-dark-border bg-dark-input/30 text-sm font-semibold text-zinc-500 dark:text-dark-muted">
Nenhuma composição sincronizada para este produto.
</div>
) : composition.components.length === 0 ? (
<div className="flex h-24 items-center justify-center rounded-xl border border-dashed border-dark-border bg-dark-input/30 text-sm font-semibold text-zinc-500 dark:text-dark-muted">
Esta composição não possui insumos.
</div>
) : (
<div className="overflow-x-auto rounded-xl border border-dark-border">
<table className="w-full min-w-[640px] text-left text-sm">
<thead className="bg-dark-input/60 text-[10px] font-bold uppercase tracking-widest text-dark-muted">
<tr>
<th className="px-4 py-3">Produto / insumo</th>
<th className="px-4 py-3">SKU</th>
<th className="px-4 py-3 text-right">Quantidade por unidade</th>
<th className="px-4 py-3">Unidade</th>
</tr>
</thead>
<tbody className="divide-y divide-dark-border">
{composition.components.map(component => (
<tr key={component.id} className="text-dark-text">
<td className="px-4 py-3 font-semibold">
{component.productId ? (
<Link to={`/products/${component.productId}`} className="text-brand-primary hover:underline">
{component.componentName}
</Link>
) : component.componentName}
</td>
<td className="px-4 py-3 font-mono text-xs text-dark-muted">{component.componentSku || '—'}</td>
<td className="px-4 py-3 text-right font-semibold">{formatNumber(component.quantityPerUnit)}</td>
<td className="px-4 py-3 text-dark-muted">{component.unit || '—'}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
{isCompositionModalOpen && (
<div className="fixed inset-0 z-50 flex items-end justify-center bg-black/70 p-0 backdrop-blur-sm sm:items-center sm:p-6" role="dialog" aria-modal="true" aria-label="Editar composição">
<div className="max-h-[92vh] w-full max-w-4xl overflow-y-auto rounded-t-2xl border border-dark-border bg-dark-card shadow-2xl sm:rounded-2xl">
<div className="sticky top-0 z-10 flex items-start justify-between border-b border-dark-border bg-dark-card p-5">
<div><h2 className="text-base font-bold text-dark-text">{composition ? 'Editar composição' : 'Adicionar composição'}</h2><p className="mt-1 text-xs font-semibold text-dark-muted">Informe o consumo necessário para produzir uma unidade.</p></div>
<button type="button" onClick={() => setIsCompositionModalOpen(false)} title="Fechar" aria-label="Fechar" className="inline-flex h-9 w-9 items-center justify-center rounded-lg border border-dark-border text-dark-muted transition-colors hover:border-brand-primary hover:text-dark-text cursor-pointer"><X className="h-4 w-4" /></button>
</div>
<div className="p-5">
{compositionError && <p className="mb-4 rounded-xl border border-red-400/30 bg-red-400/10 p-3 text-sm font-semibold text-red-300">{compositionError}</p>}
<div className="overflow-x-auto rounded-xl border border-dark-border">
<table className="w-full min-w-[760px] text-left text-sm">
<thead className="bg-dark-input/60 text-[10px] font-bold uppercase tracking-widest text-dark-muted"><tr><th className="px-3 py-3">SKU ou ID</th><th className="px-3 py-3">Insumo</th><th className="px-3 py-3">Quantidade</th><th className="px-3 py-3">Unidade</th><th className="w-12 px-3 py-3"><span className="sr-only">Remover</span></th></tr></thead>
<tbody className="divide-y divide-dark-border">
{compositionForm.map((component, index) => <tr key={`${index}-${component.componentSku}-${component.componentName}`}>
<td className="p-2"><input value={component.componentSku || component.componentTinyId} onChange={event => setCompositionForm(items => items.map((item, itemIndex) => itemIndex === index ? { ...item, componentSku: event.target.value, componentTinyId: '' } : item))} placeholder="MC.30.PTO" className="h-9 w-full rounded-lg border border-dark-border bg-dark-input px-3 text-sm text-dark-text outline-none focus:border-brand-primary" /></td>
<td className="p-2"><input value={component.componentName} onChange={event => setCompositionForm(items => items.map((item, itemIndex) => itemIndex === index ? { ...item, componentName: event.target.value } : item))} placeholder="Nome do insumo" className="h-9 w-full rounded-lg border border-dark-border bg-dark-input px-3 text-sm text-dark-text outline-none focus:border-brand-primary" /></td>
<td className="p-2"><input type="number" min="0" step="0.0001" value={component.quantityPerUnit} onChange={event => setCompositionForm(items => items.map((item, itemIndex) => itemIndex === index ? { ...item, quantityPerUnit: event.target.value } : item))} placeholder="0,0000" className="h-9 w-28 rounded-lg border border-dark-border bg-dark-input px-3 text-sm text-dark-text outline-none focus:border-brand-primary" /></td>
<td className="p-2"><input value={component.unit} onChange={event => setCompositionForm(items => items.map((item, itemIndex) => itemIndex === index ? { ...item, unit: event.target.value.toUpperCase() } : item))} placeholder="KG" className="h-9 w-20 rounded-lg border border-dark-border bg-dark-input px-3 text-sm text-dark-text outline-none focus:border-brand-primary" /></td>
<td className="p-2 text-center"><button type="button" onClick={() => setCompositionForm(items => items.filter((_, itemIndex) => itemIndex !== index))} disabled={compositionForm.length === 1} title="Remover insumo" aria-label="Remover insumo" className="inline-flex h-8 w-8 items-center justify-center rounded-lg border border-dark-border text-dark-muted transition-colors hover:border-red-400/50 hover:text-red-300 disabled:cursor-not-allowed disabled:opacity-40 cursor-pointer"><Trash2 className="h-3.5 w-3.5" /></button></td>
</tr>)}
</tbody>
</table>
</div>
<button type="button" onClick={() => setCompositionForm(items => [...items, emptyCompositionComponent()])} className="mt-3 inline-flex h-9 items-center gap-2 rounded-lg border border-dark-border px-3 text-xs font-bold text-dark-text transition-colors hover:border-brand-primary cursor-pointer"><Plus className="h-4 w-4 text-brand-primary" /> Adicionar insumo</button>
</div>
<div className="flex justify-end gap-2 border-t border-dark-border p-5"><button type="button" onClick={() => setIsCompositionModalOpen(false)} className="h-10 rounded-xl border border-dark-border px-4 text-sm font-bold text-dark-text cursor-pointer">Cancelar</button><button type="button" onClick={() => void saveComposition()} disabled={isSavingComposition} className="inline-flex h-10 items-center gap-2 rounded-xl bg-brand-primary px-4 text-sm font-bold text-brand-contrast disabled:opacity-60 cursor-pointer">{isSavingComposition ? 'Salvando...' : 'Salvar composição'}</button></div>
</div>
</div>
)}
<div className="bg-white dark:bg-dark-card border border-zinc-200 dark:border-dark-border rounded-2xl p-6 shadow-sm">
<div className="mb-5 flex flex-col gap-2 md:flex-row md:items-end md:justify-between">
<div>
<h3 className="text-lg font-bold text-zinc-900 dark:text-dark-text">Variações do Produto</h3>
<p className="mt-1 text-sm font-medium text-zinc-500 dark:text-dark-muted">Tamanhos, cores ou SKUs parecidos no mesmo período.</p>
</div>
<span className="text-xs font-bold uppercase tracking-widest text-zinc-400 dark:text-dark-muted">
{formatNumber(totalOrders)} {totalOrders === 1 ? 'pedido' : 'pedidos'}
</span>
</div>
{variantBreakdown.length === 0 ? (
<div className="flex h-32 items-center justify-center text-sm font-semibold text-zinc-500 dark:text-dark-muted">
Nenhuma variação encontrada para este produto.
</div>
) : (
<div className="space-y-3">
{paginatedVariantBreakdown.map(variant => {
const width = maxVariantQuantity ? Math.max(4, (variant.quantitySold / maxVariantQuantity) * 100) : 0;
const metadata = parseProductName(variant.name);
return (
<div key={variant.id} className={`rounded-xl border p-4 ${
variant.id === productInfo.id
? 'border-brand-primary/50 bg-brand-primary/10 shadow-sm shadow-brand-primary/10'
: 'border-dark-border bg-dark-input/45'
}`}>
<div className="flex flex-col gap-2 md:flex-row md:items-start md:justify-between">
<div className="min-w-0">
<Link to={`/products/${variant.id}`} className="block truncate text-sm font-bold text-dark-text transition-colors hover:text-brand-primary hover:underline" title={`Ver produto ${variant.name}`}>
{variant.name}
</Link>
<div className="mt-1 flex flex-wrap items-center gap-2 text-[11px] font-medium text-dark-muted">
<span>#{variant.id}</span>
{metadata.color && (
<span className="rounded-full border border-sky-400/25 bg-sky-400/10 px-2 py-0.5 font-bold text-sky-300">
{formatColorLabel(metadata.color)}
</span>
)}
{metadata.size && (
<span className="rounded-full border border-emerald-400/25 bg-emerald-400/10 px-2 py-0.5 font-bold text-emerald-300">
Tam. {metadata.size}
</span>
)}
</div>
</div>
<div className="flex shrink-0 gap-5 text-right text-xs">
<div>
<div className="font-bold text-dark-text">{formatNumber(variant.quantitySold)} un.</div>
<div className="text-dark-muted">vendidas</div>
</div>
<div>
<div className="font-bold text-brand-primary">{formatCurrency(variant.revenue)}</div>
<div className="text-dark-muted">receita</div>
</div>
</div>
</div>
<div className="mt-3 h-2 overflow-hidden rounded-full bg-dark-border">
<div
className="h-full rounded-full"
style={{
width: `${width}%`,
backgroundColor: VARIANT_BAR_COLOR,
opacity: 0.72
}}
/>
</div>
</div>
);
})}
</div>
)}
{variantBreakdown.length > variantItemsPerPage && (
<PaginationControls
totalItems={variantBreakdown.length}
currentPage={safeVariantCurrentPage}
totalPages={variantTotalPages}
pageSize={variantItemsPerPage}
pageSizeOptions={[5, 10, 20]}
itemLabel="variações"
pageSizeLabel="por página"
startIndex={variantStartIndex}
endIndex={Math.min(variantStartIndex + variantItemsPerPage, variantBreakdown.length)}
onPageChange={setVariantCurrentPage}
onPageSizeChange={(pageSize) => {
setVariantItemsPerPage(pageSize);
setVariantCurrentPage(1);
}}
className="mt-5 border-t border-dark-border pt-4"
/>
)}
</div>
</div>
{isPlanningModalOpen && (
<SkuPlanningModal
product={productInfo}
override={planningSettings.productOverrides[productInfo.id]}
isSaving={isSavingPlanning}
onClose={() => setIsPlanningModalOpen(false)}
onSave={(override) => saveProductOverride(productInfo.id, override)}
/>
)}
</div>
);
};

View File

@@ -1,623 +0,0 @@
import { useEffect, useMemo, useState } from 'react';
import { Link, useOutletContext, useParams } from 'react-router-dom';
import { DollarSign, Eye, Package, Palette, Pencil, Ruler, TrendingDown, Warehouse } from 'lucide-react';
import BackButton from '../components/BackButton';
import DateRangePicker from '../components/DateRangePicker';
import PaginationControls from '../components/PaginationControls';
import ProductColorBadge, { ProductColorSwatch } from '../components/ProductColorBadge';
import ProductTypeBadge from '../components/ProductTypeBadge';
import RefreshStatus from '../components/RefreshStatus';
import { buildSkuEditPath } from '../catalogLinks';
import { buildOpenProductionByProductId } from '../analytics/cutting';
import { fetchCuttingSettings, fetchProductAnalytics, fetchProductionOrders } from '../dataService';
import { decodeProductGroupKey, normalizeProductText, parseProductName } from '../productParsing';
import { formatColorLabel } from '../displayFormatters';
import { getProductColor } from '../productColors';
import { getDominantProductType, getProductTypeConfig, resolveProductType, type ProductTypeKey } from '../productClassification';
import { getPlanningStock } from '../planningStock';
import type { CuttingSettings, DateRange, ProductAnalyticsItem, ProductionOrderItem } from '../types';
type VariantRow = ProductAnalyticsItem & {
color: string;
size: string;
dailySales: number;
projectedDemand: number;
openProductionQuantity: number;
availableQuantity: number;
suggestedReplenishment: number;
daysOfCover: number | null;
productType: ProductTypeKey;
};
type BreakdownRow = {
label: string;
quantitySold: number;
revenue: number;
stock: number;
skuCount: number;
};
const BREAKDOWN_LIMIT = 12;
const REPLENISHMENT_TARGET_DAYS = 30;
const allProductionOrdersRange = {
start: new Date(2000, 0, 1),
end: new Date(2100, 11, 31)
};
const getRangeDays = (range: DateRange) => {
const start = new Date(range.start);
const end = new Date(range.end);
start.setHours(0, 0, 0, 0);
end.setHours(0, 0, 0, 0);
return Math.max(1, Math.round((end.getTime() - start.getTime()) / 86_400_000) + 1);
};
const formatNumber = (value: number, maximumFractionDigits = 0) => (
new Intl.NumberFormat('pt-BR', { maximumFractionDigits }).format(value)
);
const formatCurrency = (value: number) => (
new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(value)
);
const formatDays = (value: number | null) => {
if (value === null) return '-';
if (value > 999) return '999+ dias';
return `${formatNumber(value, value < 10 ? 1 : 0)} dias`;
};
const getBarColor = (label: string) => {
const color = getProductColor(label);
return `color-mix(in srgb, ${color} 74%, var(--color-dark-text) 26%)`;
};
const buildBreakdown = (rows: VariantRow[], field: 'color' | 'size') => {
const totals = new Map<string, BreakdownRow>();
rows.forEach(row => {
const label = row[field] || (field === 'color' ? 'Sem cor' : 'Sem tamanho');
const current = totals.get(label) || { label, quantitySold: 0, revenue: 0, stock: 0, skuCount: 0 };
totals.set(label, {
label,
quantitySold: current.quantitySold + row.quantitySold,
revenue: current.revenue + row.revenue,
stock: current.stock + row.stock,
skuCount: current.skuCount + 1
});
});
return [...totals.values()].sort((a, b) => b.quantitySold - a.quantitySold);
};
const BreakdownPanel = ({
title,
subtitle,
rows,
type
}: {
title: string;
subtitle: string;
rows: BreakdownRow[];
type: 'color' | 'size';
}) => {
const maxSold = Math.max(...rows.map(row => row.quantitySold), 0);
const visibleRows = rows.slice(0, BREAKDOWN_LIMIT);
const hiddenCount = Math.max(0, rows.length - visibleRows.length);
return (
<div className="rounded-2xl border border-dark-border bg-dark-card p-5 shadow-sm">
<div className="mb-5 flex items-start justify-between gap-4">
<div>
<h3 className="text-base font-bold text-dark-text">{title}</h3>
<p className="mt-1 text-sm font-medium text-dark-muted">{subtitle}</p>
</div>
{type === 'color' ? (
<Palette className="h-5 w-5 text-brand-primary" />
) : (
<Ruler className="h-5 w-5 text-brand-primary" />
)}
</div>
{rows.length === 0 ? (
<div className="flex h-32 items-center justify-center text-sm font-semibold text-dark-muted">
Sem dados para este grupo.
</div>
) : (
<div className="space-y-3">
{visibleRows.map(row => {
const width = maxSold ? Math.max(4, (row.quantitySold / maxSold) * 100) : 0;
const barColor = type === 'color' ? getBarColor(row.label) : '#25c2ff';
const displayLabel = type === 'color' ? formatColorLabel(row.label) : row.label;
return (
<div key={row.label} className="grid grid-cols-[minmax(7.5rem,9rem)_1fr_6rem] items-center gap-3">
<div className="flex min-w-0 items-center gap-2">
{type === 'color' ? <ProductColorSwatch label={row.label} /> : <span className="h-2.5 w-2.5 shrink-0 rounded-sm bg-sky-400" />}
<span className="truncate text-xs font-bold text-dark-text" title={displayLabel}>
{type === 'size' && row.label !== 'Sem tamanho' ? `Tam. ${row.label}` : displayLabel}
</span>
</div>
<div className="h-3 overflow-hidden rounded-full border border-dark-border bg-dark-input">
<div
className="h-full rounded-full"
style={{ width: `${width}%`, backgroundColor: barColor }}
/>
</div>
<div className="text-right text-xs">
<div className="font-bold text-dark-text">{formatNumber(row.quantitySold)} un.</div>
<div className="text-[10px] font-semibold text-dark-muted">{row.skuCount} SKUs</div>
</div>
</div>
);
})}
{hiddenCount > 0 && (
<div className="border-t border-dark-border pt-3 text-xs font-semibold text-dark-muted">
+{formatNumber(hiddenCount)} itens fora do top {BREAKDOWN_LIMIT}
</div>
)}
</div>
)}
</div>
);
};
const ProductGroupDetailsSkeleton = () => (
<div className="space-y-6" aria-label="Carregando grupo de produtos">
<div className="flex flex-col gap-4">
<div className="skeleton h-4 w-20" />
<div className="flex items-center gap-4">
<div className="skeleton h-16 w-16 rounded-2xl" />
<div className="w-full max-w-xl">
<div className="skeleton h-3 w-24" />
<div className="skeleton mt-3 h-7 w-full" />
</div>
</div>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-4">
{[0, 1, 2, 3].map(item => (
<div key={`group-kpi-skeleton-${item}`} className="skeleton h-28 rounded-2xl" />
))}
</div>
<div className="grid grid-cols-1 gap-4 xl:grid-cols-2">
<div className="skeleton h-80 rounded-2xl" />
<div className="skeleton h-80 rounded-2xl" />
</div>
</div>
);
const ProductGroupDetails = () => {
const { groupKey } = useParams<{ groupKey: string }>();
const { dateRange, setDateRange } = useOutletContext<{
dateRange: DateRange,
setDateRange: (range: DateRange) => void
}>();
const [products, setProducts] = useState<ProductAnalyticsItem[]>([]);
const [productionOrders, setProductionOrders] = useState<ProductionOrderItem[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [planningSettings, setPlanningSettings] = useState<CuttingSettings>({ familyYields: {}, productOverrides: {} });
const [currentPage, setCurrentPage] = useState(1);
const [itemsPerPage, setItemsPerPage] = useState(20);
const groupName = useMemo(() => {
if (!groupKey) return '';
try {
return decodeProductGroupKey(groupKey);
} catch {
return '';
}
}, [groupKey]);
useEffect(() => {
let isMounted = true;
const loadPlanningSettings = async () => {
const settings = await fetchCuttingSettings();
if (isMounted) setPlanningSettings(settings);
};
void loadPlanningSettings();
return () => {
isMounted = false;
};
}, []);
useEffect(() => {
let isMounted = true;
const loadProducts = async () => {
setIsLoading(true);
const [productData, productionOrderData] = await Promise.all([
fetchProductAnalytics(dateRange),
fetchProductionOrders(allProductionOrdersRange)
]);
if (isMounted) {
setProducts(productData);
setProductionOrders(productionOrderData.orders);
setIsLoading(false);
}
};
void loadProducts();
return () => {
isMounted = false;
};
}, [dateRange]);
const openProductionByProductId = useMemo(
() => buildOpenProductionByProductId(products, productionOrders),
[products, productionOrders]
);
const groupRows = useMemo<VariantRow[]>(() => {
const rangeDays = getRangeDays(dateRange);
const normalizedGroupName = normalizeProductText(groupName).toLowerCase();
return products
.map(product => {
const metadata = parseProductName(product.name);
const productType = resolveProductType(product.name, planningSettings.productOverrides[product.id]);
const dailySales = product.quantitySold / rangeDays;
const projectedDemand = dailySales * REPLENISHMENT_TARGET_DAYS;
const openProductionQuantity = openProductionByProductId[product.id] || 0;
const availableQuantity = getPlanningStock(product.stock) + openProductionQuantity;
const suggestedReplenishment = Math.max(0, Math.ceil(projectedDemand - availableQuantity));
return {
...product,
color: metadata.color,
size: metadata.size,
baseName: metadata.baseName,
dailySales,
projectedDemand,
openProductionQuantity,
availableQuantity,
suggestedReplenishment,
daysOfCover: dailySales > 0 ? availableQuantity / dailySales : null,
productType
};
})
.filter(product => normalizeProductText(product.baseName).toLowerCase() === normalizedGroupName)
.sort((a, b) => b.quantitySold - a.quantitySold);
}, [dateRange, groupName, openProductionByProductId, planningSettings.productOverrides, products]);
const totals = useMemo(() => {
const totalSold = groupRows.reduce((total, row) => total + row.quantitySold, 0);
const totalRevenue = groupRows.reduce((total, row) => total + row.revenue, 0);
const totalStock = groupRows.reduce((total, row) => total + row.stock, 0);
const openProductionQuantity = groupRows.reduce((total, row) => total + row.openProductionQuantity, 0);
const availableQuantity = groupRows.reduce((total, row) => total + row.availableQuantity, 0);
const dailySales = groupRows.reduce((total, row) => total + row.dailySales, 0);
const projectedDemand = groupRows.reduce((total, row) => total + row.projectedDemand, 0);
const suggestedReplenishment = Math.max(0, Math.ceil(projectedDemand - availableQuantity));
const daysOfCover = dailySales > 0 ? availableQuantity / dailySales : null;
const colors = new Set(groupRows.map(row => row.color).filter(Boolean));
const sizes = new Set(groupRows.map(row => row.size).filter(Boolean));
const productType = getDominantProductType(groupRows);
return {
totalSold,
totalRevenue,
totalStock,
openProductionQuantity,
availableQuantity,
dailySales,
projectedDemand,
suggestedReplenishment,
daysOfCover,
colorCount: colors.size,
sizeCount: sizes.size,
productType
};
}, [groupRows]);
const productTypeConfig = getProductTypeConfig(totals.productType);
const colorBreakdown = useMemo(() => buildBreakdown(groupRows, 'color'), [groupRows]);
const sizeBreakdown = useMemo(() => buildBreakdown(groupRows, 'size'), [groupRows]);
const replenishmentDrivers = useMemo(() => (
groupRows
.filter(row => row.suggestedReplenishment > 0)
.sort((a, b) => b.suggestedReplenishment - a.suggestedReplenishment)
.slice(0, 5)
), [groupRows]);
const isRefreshing = isLoading && products.length > 0;
const totalPages = Math.ceil(groupRows.length / itemsPerPage);
const safeCurrentPage = Math.min(currentPage, totalPages || 1);
const startIndex = (safeCurrentPage - 1) * itemsPerPage;
const paginatedRows = groupRows.slice(startIndex, startIndex + itemsPerPage);
if (isLoading && products.length === 0) {
return <ProductGroupDetailsSkeleton />;
}
if (!groupName || groupRows.length === 0) {
return (
<div className="py-12 text-center">
<p className="font-medium text-zinc-500 dark:text-dark-muted">Grupo de produtos não encontrado.</p>
<Link to="/products" className="mt-4 inline-block font-bold text-brand-primary hover:underline">
Voltar para produtos
</Link>
</div>
);
}
return (
<div className="space-y-6">
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div className="flex flex-col gap-4">
<BackButton fallbackTo="/products" />
<div className="flex items-center gap-4">
<div className="flex h-16 w-16 items-center justify-center rounded-2xl border border-zinc-200 bg-white text-brand-primary shadow-sm dark:border-dark-border dark:bg-dark-card">
<Package className="h-8 w-8" />
</div>
<div className="min-w-0">
<p className="text-xs font-bold uppercase tracking-widest text-zinc-400 dark:text-dark-muted">
Grupo · {formatNumber(groupRows.length)} SKUs · {formatNumber(totals.colorCount)} cores · {formatNumber(totals.sizeCount)} tamanhos
</p>
<h1 className="truncate text-2xl font-bold text-zinc-900 dark:text-dark-text" title={groupName}>{groupName}</h1>
<div className="mt-2 flex flex-wrap items-center gap-2">
<ProductTypeBadge type={totals.productType} />
<span className="text-xs font-semibold text-dark-muted">{productTypeConfig.description}</span>
</div>
</div>
</div>
</div>
<DateRangePicker
dateRange={dateRange}
onChange={(range) => {
setDateRange(range);
setCurrentPage(1);
}}
/>
</div>
<RefreshStatus isRefreshing={isRefreshing} />
<div className={isRefreshing ? 'refreshing-content space-y-6' : 'space-y-6'} aria-busy={isRefreshing}>
<div className="grid grid-cols-1 gap-4 md:grid-cols-4">
<div className="rounded-2xl border border-dark-border bg-dark-card p-5 shadow-sm">
<div className="flex items-center justify-between gap-4">
<div>
<p className="mb-1 text-xs font-bold uppercase tracking-widest text-dark-muted">Unidades vendidas</p>
<p className="text-3xl font-bold text-dark-text">{formatNumber(totals.totalSold)}</p>
</div>
<Package className="h-6 w-6 text-brand-primary" />
</div>
</div>
<div className="rounded-2xl border border-dark-border bg-dark-card p-5 shadow-sm">
<div className="flex items-center justify-between gap-4">
<div>
<p className="mb-1 text-xs font-bold uppercase tracking-widest text-dark-muted">Receita total</p>
<p className="text-3xl font-bold text-dark-text">{formatCurrency(totals.totalRevenue)}</p>
</div>
<DollarSign className="h-6 w-6 text-emerald-300" />
</div>
</div>
<div className="rounded-2xl border border-dark-border bg-dark-card p-5 shadow-sm">
<div className="flex items-center justify-between gap-4">
<div>
<p className="mb-1 text-xs font-bold uppercase tracking-widest text-dark-muted">Estoque</p>
<p className="text-3xl font-bold text-dark-text">{formatNumber(totals.totalStock)}</p>
</div>
<Warehouse className="h-6 w-6 text-purple-300" />
</div>
</div>
<div className="rounded-2xl border border-dark-border bg-dark-card p-5 shadow-sm">
<div className="flex items-center justify-between gap-4">
<div>
<p className="mb-1 text-xs font-bold uppercase tracking-widest text-dark-muted">Cobertura estimada</p>
<p className="text-3xl font-bold text-dark-text">{formatDays(totals.daysOfCover)}</p>
</div>
<TrendingDown className="h-6 w-6 text-sky-300" />
</div>
</div>
</div>
<div className="rounded-2xl border border-dark-border bg-dark-card p-5 shadow-sm">
<div className="mb-4 flex flex-col gap-2 md:flex-row md:items-start md:justify-between">
<div>
<h2 className="text-base font-bold text-dark-text">Contexto de reposição</h2>
<p className="mt-1 text-sm font-medium text-dark-muted">
Projeção para {REPLENISHMENT_TARGET_DAYS} dias usando vendas do período, estoque atual e OP aberta quando encontrada.
</p>
</div>
<span className={`w-fit rounded-full border px-3 py-1 text-xs font-bold ${totals.suggestedReplenishment > 0 ? 'border-red-400/30 bg-red-400/10 text-red-300' : 'border-emerald-400/30 bg-emerald-400/10 text-emerald-300'}`}>
{totals.suggestedReplenishment > 0 ? 'Com necessidade' : 'Coberto'}
</span>
</div>
<div className="grid grid-cols-1 gap-3 md:grid-cols-4">
<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">Sugestão</p>
<p className={`mt-2 text-2xl font-bold ${totals.suggestedReplenishment > 0 ? 'text-red-300' : 'text-emerald-300'}`}>
{formatNumber(totals.suggestedReplenishment)} 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">Demanda 30 dias</p>
<p className="mt-2 text-2xl font-bold text-dark-text">{formatNumber(totals.projectedDemand, 1)} 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</p>
<p className="mt-2 text-2xl font-bold text-dark-text">{formatNumber(totals.availableQuantity)} un.</p>
{!!totals.openProductionQuantity && (
<p className="mt-1 text-xs font-semibold text-dark-muted">Inclui OP {formatNumber(totals.openProductionQuantity)} 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">Cobertura</p>
<p className="mt-2 text-2xl font-bold text-dark-text">{formatDays(totals.daysOfCover)}</p>
</div>
</div>
<div className="mt-4 rounded-xl border border-dark-border bg-dark-input/35">
<div className="border-b border-dark-border px-4 py-3">
<h3 className="text-xs font-bold uppercase tracking-widest text-dark-muted">Principais drivers</h3>
</div>
{replenishmentDrivers.length ? (
<div className="divide-y divide-dark-border">
{replenishmentDrivers.map(row => (
<div key={row.id} className="grid grid-cols-[1fr_auto] gap-4 px-4 py-3">
<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-2">
<ProductColorBadge label={row.color} className="max-w-[8rem]" />
<span className="rounded-full border border-emerald-400/25 bg-emerald-400/10 px-2.5 py-1 text-xs font-bold text-emerald-300">
{row.size || 'Sem tamanho'}
</span>
<span className="text-xs font-semibold text-dark-muted">
Cobertura {formatDays(row.daysOfCover)}
</span>
</div>
</div>
<div className="text-right">
<p className="text-sm font-bold text-red-300">{formatNumber(row.suggestedReplenishment)} un.</p>
<p className="mt-1 text-[10px] font-semibold text-dark-muted">sugerido</p>
</div>
</div>
))}
</div>
) : (
<div className="px-4 py-6 text-sm font-semibold text-dark-muted">
Nenhum SKU do grupo está abaixo da cobertura projetada.
</div>
)}
</div>
</div>
<div className="grid grid-cols-1 gap-4 xl:grid-cols-2">
<BreakdownPanel
title="Venda por cor"
subtitle="Cores mais vendidas dentro deste grupo."
rows={colorBreakdown}
type="color"
/>
<BreakdownPanel
title="Venda por tamanho"
subtitle="Tamanhos mais vendidos dentro deste grupo."
rows={sizeBreakdown}
type="size"
/>
</div>
<div className="overflow-hidden rounded-2xl border border-zinc-200 bg-white shadow-sm dark:border-dark-border dark:bg-dark-card">
<div className="flex flex-col gap-2 border-b border-zinc-100 px-6 py-4 dark:border-dark-border md:flex-row md:items-end md:justify-between">
<div>
<h3 className="text-base font-bold text-zinc-900 dark:text-dark-text">Variações do grupo</h3>
<p className="mt-1 text-sm font-medium text-zinc-500 dark:text-dark-muted">SKUs, cores e tamanhos que formam este grupo.</p>
</div>
<span className="text-xs font-bold uppercase tracking-widest text-zinc-400 dark:text-dark-muted">
{formatNumber(groupRows.length)} SKUs
</span>
</div>
<div className="overflow-x-auto">
<table className="w-full min-w-[1100px] table-fixed text-left text-sm">
<colgroup>
<col className="w-[150px]" />
<col className="w-[360px]" />
<col className="w-[120px]" />
<col className="w-[100px]" />
<col className="w-[120px]" />
<col className="w-[120px]" />
<col className="w-[140px]" />
<col className="w-[130px]" />
<col className="w-[130px]" />
</colgroup>
<thead className="border-b border-zinc-100 bg-zinc-50 text-zinc-500 dark:border-dark-border dark:bg-dark-header dark:text-dark-muted">
<tr>
<th className="px-6 py-4 text-[10px] font-bold uppercase tracking-wider">ID Produto</th>
<th className="px-6 py-4 text-[10px] font-bold uppercase tracking-wider">Descrição</th>
<th className="px-6 py-4 text-[10px] font-bold uppercase tracking-wider">Cor</th>
<th className="px-6 py-4 text-[10px] font-bold uppercase tracking-wider">Tamanho</th>
<th className="px-6 py-4 text-[10px] font-bold uppercase tracking-wider">Vendido</th>
<th className="px-6 py-4 text-[10px] font-bold uppercase tracking-wider">Estoque</th>
<th className="px-6 py-4 text-[10px] font-bold uppercase tracking-wider">Média diária</th>
<th className="px-6 py-4 text-[10px] font-bold uppercase tracking-wider">Receita</th>
<th className="px-6 py-4 text-right text-[10px] font-bold uppercase tracking-wider">Ações</th>
</tr>
</thead>
<tbody className="divide-y divide-zinc-100 dark:divide-dark-border">
{paginatedRows.map(row => (
<tr key={row.id} className="transition-colors hover:bg-zinc-50/80 dark:hover:bg-dark-input/50">
<td className="px-6 py-2.5 font-mono text-[11px] text-zinc-400 dark:text-dark-muted">#{row.id}</td>
<td className="max-w-0 px-6 py-2.5">
<div className="truncate font-semibold text-zinc-900 dark:text-dark-text" title={row.name}>{row.name}</div>
<div className="mt-1 flex min-w-0 items-center gap-2">
<ProductTypeBadge type={row.productType} />
<span className="truncate text-[10px] font-medium text-zinc-400 dark:text-dark-muted">Preço Atual: {formatCurrency(row.lastPrice)}</span>
</div>
</td>
<td className="px-6 py-2.5">
<ProductColorBadge label={row.color} className="max-w-[8rem]" />
</td>
<td className="px-6 py-2.5">
<span className="inline-flex rounded-full border border-emerald-400/25 bg-emerald-400/10 px-2.5 py-1 text-xs font-bold text-emerald-300">
{row.size || '-'}
</span>
</td>
<td className="px-6 py-2.5 whitespace-nowrap">
<div className="flex min-w-0 items-center gap-2">
<Package className="h-3.5 w-3.5 shrink-0 text-zinc-400 dark:text-dark-muted" />
<span className="min-w-0 font-bold tabular-nums text-zinc-900 dark:text-dark-text">{formatNumber(row.quantitySold)} un.</span>
</div>
</td>
<td className="px-6 py-2.5 whitespace-nowrap font-bold text-zinc-900 dark:text-dark-text">{formatNumber(row.stock)} un.</td>
<td className="px-6 py-2.5 whitespace-nowrap text-zinc-500 dark:text-dark-muted">{formatNumber(row.dailySales, 2)} un./dia</td>
<td className="px-6 py-2.5 whitespace-nowrap font-bold text-brand-primary">{formatCurrency(row.revenue)}</td>
<td className="px-4 py-2.5 text-right">
<div className="flex justify-end gap-2">
<Link
to={buildSkuEditPath({ sku: row.id, name: row.name, color: row.color, size: row.size })}
className="inline-flex h-8 w-8 items-center justify-center rounded-lg bg-dark-input text-dark-text transition-colors hover:bg-dark-border"
title={`Editar SKU ${row.id}`}
aria-label={`Editar SKU ${row.id}`}
>
<Pencil className="h-3.5 w-3.5" />
</Link>
<Link
to={`/products/${row.id}`}
className="inline-flex h-8 w-8 items-center justify-center rounded-lg bg-brand-primary/10 text-brand-primary transition-opacity hover:opacity-80"
title={`Ver SKU ${row.id}`}
aria-label={`Ver SKU ${row.id}`}
>
<Eye className="h-3.5 w-3.5" />
</Link>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
<PaginationControls
totalItems={groupRows.length}
currentPage={safeCurrentPage}
totalPages={totalPages}
pageSize={itemsPerPage}
pageSizeOptions={[10, 20, 50, 100]}
itemLabel="SKUs"
pageSizeLabel="SKUs por página"
startIndex={startIndex}
endIndex={Math.min(startIndex + itemsPerPage, groupRows.length)}
onPageChange={setCurrentPage}
onPageSizeChange={(pageSize) => {
setItemsPerPage(pageSize);
setCurrentPage(1);
}}
/>
</div>
</div>
</div>
);
};
export default ProductGroupDetails;

View File

@@ -1,774 +0,0 @@
import { type FormEvent, useCallback, useEffect, useMemo, useState } from 'react';
import { Link, useNavigate, useOutletContext } from 'react-router-dom';
import { AlertTriangle, ArrowLeft, CheckCircle2, ChevronDown, ChevronUp, ClipboardList, Clock3, Download, PackageCheck, PlayCircle, Printer, Scissors, Search, Undo2, X, XCircle } from 'lucide-react';
import DateRangePicker from '../components/DateRangePicker';
import PaginationControls from '../components/PaginationControls';
import RefreshStatus from '../components/RefreshStatus';
import { addProductionOrderDefect, consumeSupplyLotForProduction, exportToCSV, fetchProductionOrders, fetchProductionWorkstations, fetchSupplySummary, updateProductionOrderExecution, updateProductionOrderStatus } from '../dataService';
import type { DateRange, ProductionOrderItem, ProductionOrderStatus, ProductionOrderSummary, ProductionWorkstation, SupplyLot } from '../types';
type ProductionOrderStatusTab = 'all' | 'open' | 'cutting' | 'sewing' | 'review' | 'finished' | 'canceled';
const emptySummary: ProductionOrderSummary = {
orders: [],
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: '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: '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.' }
];
const statusStyles: Record<string, { label: string; className: string; dotClass: string }> = {
open: {
label: 'Em aberto',
className: 'border-amber-400/35 bg-amber-400/10 text-amber-300',
dotClass: 'bg-amber-400'
},
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',
dotClass: 'bg-emerald-400'
},
canceled: {
label: 'Cancelada',
className: 'border-zinc-500/35 bg-zinc-500/10 text-zinc-400',
dotClass: 'bg-zinc-500'
}
};
const formatDate = (date: string | null) => {
if (!date) return '-';
const parsedDate = new Date(`${date}T00:00:00`);
if (Number.isNaN(parsedDate.getTime())) return date;
return new Intl.DateTimeFormat('pt-BR').format(parsedDate);
};
const formatQuantity = (value: number) => (
new Intl.NumberFormat('pt-BR', {
minimumFractionDigits: Number.isInteger(value) ? 0 : 2,
maximumFractionDigits: 4
}).format(value)
);
const getStatusStyle = (status: ProductionOrderStatus, fallbackLabel: string) => (
statusStyles[String(status)] || {
label: fallbackLabel || 'Em aberto',
className: 'border-dark-border bg-dark-input text-dark-muted',
dotClass: 'bg-dark-muted'
}
);
const getNextStage = (status: ProductionOrderStatus): { status: Exclude<ProductionOrderStatusTab, 'all'>; label: string; description: string } | null => {
switch (status) {
case 'open': return { status: 'cutting', label: 'Iniciar corte', description: 'Libera o apontamento de corte e a baixa física de materiais.' };
case 'cutting': return { status: 'sewing', label: 'Avançar para costura', description: 'Use quando o corte desta OP estiver concluído.' };
case 'sewing': return { status: 'review', label: 'Enviar para revisão', description: 'Use quando as peças estiverem prontas para a conferência final.' };
case 'review': return { status: 'finished', label: 'Finalizar OP', description: 'Confirme a quantidade aprovada antes de encerrar a ordem.' };
default: return null;
}
};
const getPreviousStage = (status: ProductionOrderStatus): { status: Exclude<ProductionOrderStatusTab, 'all'>; label: string; description: string } | null => {
switch (status) {
case 'cutting': return { status: 'open', label: 'Voltar para em aberto', description: 'Use quando a produção ainda não foi iniciada.' };
case 'sewing': return { status: 'cutting', label: 'Voltar para corte', description: 'Use quando esta OP precisa retornar à etapa de corte.' };
case 'review': return { status: 'sewing', label: 'Voltar para costura', description: 'Use quando a revisão identificar um ajuste na montagem.' };
case 'finished': return { status: 'review', label: 'Reabrir em revisão', description: 'Use para corrigir uma OP finalizada antes de encerrá-la novamente.' };
default: return null;
}
};
const canReturnToCutPlan = (order: ProductionOrderItem) => (
!['finished', 'canceled'].includes(order.status)
&& (order.integrationStatus || '').toLowerCase() === 'local'
);
const ProductionOrdersSkeleton = () => (
<div className="space-y-6" aria-label="Carregando ordens de produção">
<div className="grid grid-cols-1 gap-4 md:grid-cols-4">
{[0, 1, 2, 3].map(item => (
<div key={`production-kpi-skeleton-${item}`} className="rounded-2xl border border-dark-border bg-dark-card p-5 shadow-sm">
<div className="skeleton h-3 w-28" />
<div className="skeleton mt-3 h-8 w-20" />
<div className="skeleton mt-3 h-3 w-36" />
</div>
))}
</div>
<div className="rounded-2xl border border-dark-border bg-dark-card shadow-sm">
<div className="border-b border-dark-border p-5">
<div className="flex flex-col gap-4 xl:flex-row xl:items-center xl:justify-between">
<div className="skeleton h-10 w-full max-w-xl" />
<div className="flex gap-3">
<div className="skeleton h-10 w-32" />
<div className="skeleton h-10 w-28" />
</div>
</div>
<div className="mt-5 flex gap-4">
{[0, 1, 2, 3, 4].map(item => (
<div key={`production-tab-skeleton-${item}`} className="skeleton h-8 w-28" />
))}
</div>
</div>
<div className="divide-y divide-dark-border">
{[0, 1, 2, 3, 4, 5, 6, 7].map(row => (
<div key={`production-row-skeleton-${row}`} className="grid grid-cols-[90px_120px_120px_1.5fr_110px_180px_110px] gap-5 px-6 py-4">
{[0, 1, 2, 3, 4, 5, 6].map(column => (
<div key={`production-cell-skeleton-${row}-${column}`} className="skeleton h-4" />
))}
</div>
))}
</div>
</div>
</div>
);
const ProductionOrders = () => {
const { dateRange, setDateRange, refreshInterval, setRefreshInterval } = useOutletContext<{
dateRange: DateRange;
setDateRange: (range: DateRange) => void;
refreshInterval: number;
setRefreshInterval: (interval: number) => void;
}>();
const navigate = useNavigate();
const [searchTerm, setSearchTerm] = useState('');
const [statusFilter, setStatusFilter] = useState<ProductionOrderStatusTab>('all');
const [summary, setSummary] = useState<ProductionOrderSummary>(emptySummary);
const [supplyLots, setSupplyLots] = useState<SupplyLot[]>([]);
const [workstations, setWorkstations] = useState<ProductionWorkstation[]>([]);
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 [busyDefectOrderId, setBusyDefectOrderId] = useState<number | null>(null);
const [defectMessage, setDefectMessage] = useState('');
const [busyStatusOrderId, setBusyStatusOrderId] = useState<number | null>(null);
const [statusMessage, setStatusMessage] = useState('');
const [selectedOrderId, setSelectedOrderId] = useState<number | null>(null);
const [statusEditorOrderId, setStatusEditorOrderId] = useState<number | null>(null);
const [currentPage, setCurrentPage] = useState(1);
const [itemsPerPage, setItemsPerPage] = useState(20);
const [exitForm, setExitForm] = useState({
lotId: '',
quantity: '',
reason: '',
});
const loadProductionOrders = useCallback(async (options?: { force?: boolean }) => {
setIsLoading(true);
const nextSummary = await fetchProductionOrders(dateRange, { search: searchTerm }, options);
setSummary(nextSummary);
setIsLoading(false);
}, [dateRange, searchTerm]);
useEffect(() => {
let isMounted = true;
const load = async () => {
setIsLoading(true);
const [nextSummary, nextSupplySummary, nextWorkstations] = await Promise.all([
fetchProductionOrders(dateRange, { search: searchTerm }),
fetchSupplySummary(),
fetchProductionWorkstations()
]);
if (isMounted) {
setSummary(nextSummary);
setSupplyLots(nextSupplySummary.lots);
setWorkstations(nextWorkstations);
setIsLoading(false);
}
};
void load();
return () => {
isMounted = false;
};
}, [dateRange, searchTerm]);
useEffect(() => {
if (refreshInterval === 0) return undefined;
const intervalId = setInterval(() => {
void loadProductionOrders({ force: true });
}, refreshInterval);
return () => clearInterval(intervalId);
}, [loadProductionOrders, refreshInterval]);
const filteredOrders = useMemo(() => {
if (statusFilter === 'all') return summary.orders;
return summary.orders.filter(order => order.status === statusFilter);
}, [statusFilter, summary.orders]);
const totalPages = Math.ceil(filteredOrders.length / itemsPerPage);
const safeCurrentPage = Math.min(currentPage, totalPages || 1);
const startIndex = (safeCurrentPage - 1) * itemsPerPage;
const paginatedOrders = filteredOrders.slice(startIndex, startIndex + itemsPerPage);
const isRefreshing = isLoading && summary.orders.length > 0;
const openCount = summary.counts.open || 0;
const progressCount = (summary.counts.cutting || 0) + (summary.counts.sewing || 0) + (summary.counts.review || 0);
const finishedCount = summary.counts.finished || 0;
const finishedQuantity = summary.orders.filter(order => order.status === 'finished').reduce((total, order) => total + (order.finishedQuantity ?? order.quantity), 0);
const selectedOrder = summary.orders.find(order => order.id === selectedOrderId) || null;
const selectedOrderNextStage = selectedOrder ? getNextStage(selectedOrder.status) : null;
const selectedOrderPreviousStage = selectedOrder ? getPreviousStage(selectedOrder.status) : null;
const selectedOrderCanReturnToCutPlan = Boolean(selectedOrder && canReturnToCutPlan(selectedOrder));
const handleManualRefresh = () => {
void loadProductionOrders({ force: true });
};
const handleStatusChange = async (order: ProductionOrderItem, status: ProductionOrderStatusTab) => {
if (status === 'all' || status === order.status) return;
setBusyStatusOrderId(order.id);
setStatusMessage('');
try {
await updateProductionOrderStatus(order.id, status);
const nextSummary = await fetchProductionOrders(dateRange, { search: searchTerm }, { force: true });
setSummary(nextSummary);
setStatusMessage(`OP ${order.number || `#${order.id}`} atualizada para ${statusStyles[status]?.label || status}.`);
} catch (error) {
setStatusMessage(error instanceof Error ? error.message : 'Não foi possível atualizar o status da OP.');
} finally {
setBusyStatusOrderId(null);
setStatusEditorOrderId(null);
}
};
const returnOrderToCutPlan = async (order: ProductionOrderItem) => {
if (!canReturnToCutPlan(order)) return;
const orderNumber = order.number || `#${order.id}`;
if (!window.confirm(`Devolver a OP ${orderNumber} ao Plano de Corte?\n\nA OP será cancelada, preservando o histórico. A necessidade voltará para o Plano de Corte e poderá ser gerada novamente quando você decidir.`)) return;
setBusyStatusOrderId(order.id);
setStatusMessage('');
try {
await updateProductionOrderStatus(order.id, 'canceled');
setSelectedOrderId(null);
setStatusMessage(`OP ${orderNumber} devolvida ao Plano de Corte.`);
navigate('/supplies/cutting');
} catch (error) {
setStatusMessage(error instanceof Error ? error.message : 'Não foi possível devolver a OP ao Plano de Corte.');
} finally {
setBusyStatusOrderId(null);
}
};
const refreshSupplyLots = async () => {
const supplySummary = await fetchSupplySummary();
setSupplyLots(supplySummary.lots);
};
const selectedLot = supplyLots.find(lot => `${lot.id}` === exitForm.lotId);
const handleProductionExit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
const lotId = Number(exitForm.lotId);
const quantity = Number(exitForm.quantity.replace(',', '.'));
if (!lotId || !selectedOrder || !Number.isFinite(quantity) || quantity <= 0) return;
setIsSupplyBusy(true);
setSupplyMessage('');
try {
await consumeSupplyLotForProduction(lotId, {
quantity,
productionOrderNumber: selectedOrder.number || `${selectedOrder.id}`,
reason: exitForm.reason.trim() || selectedOrder.productDescription,
});
await refreshSupplyLots();
setExitForm({ lotId: '', quantity: '', reason: '' });
setSupplyMessage('Saída de material registrada no estoque.');
} catch (error) {
setSupplyMessage(error instanceof Error ? error.message : 'Não foi possível baixar o material.');
} finally {
setIsSupplyBusy(false);
}
};
const handleExecutionSave = async (event: FormEvent<HTMLFormElement>, order: ProductionOrderItem) => {
event.preventDefault();
const form = new FormData(event.currentTarget);
const parseOptionalQuantity = (name: string) => {
const raw = String(form.get(name) || '').trim();
return raw ? Number(raw.replace(',', '.')) : null;
};
setBusyExecutionOrderId(order.id);
setExecutionMessage('');
try {
await updateProductionOrderExecution(order.id, {
actualCutQuantity: parseOptionalQuantity('actualCutQuantity'),
rejectedQuantity: parseOptionalQuantity('rejectedQuantity'),
finishedQuantity: parseOptionalQuantity('finishedQuantity'),
qualityNotes: String(form.get('qualityNotes') || '').trim(),
markerReference: String(form.get('markerReference') || '').trim(),
});
const nextSummary = await fetchProductionOrders(dateRange, { search: searchTerm }, { force: true });
setSummary(nextSummary);
setExecutionMessage(`Execução da OP ${order.number || `#${order.id}`} registrada.`);
} catch (error) {
setExecutionMessage(error instanceof Error ? error.message : 'Não foi possível registrar a execução.');
} finally {
setBusyExecutionOrderId(null);
}
};
const handleDefectSave = async (event: FormEvent<HTMLFormElement>, order: ProductionOrderItem) => {
event.preventDefault();
const form = new FormData(event.currentTarget);
const quantity = Number(String(form.get('quantity') || '').replace(',', '.'));
const workstationId = Number(form.get('workstationId'));
if (!workstationId || !Number.isFinite(quantity) || quantity <= 0) return;
setBusyDefectOrderId(order.id);
setDefectMessage('');
try {
await addProductionOrderDefect(order.id, {
workstationId,
defectType: String(form.get('defectType') || '').trim(),
origin: String(form.get('origin') || '').trim(),
quantity,
lotCode: String(form.get('lotCode') || '').trim(),
notes: String(form.get('notes') || '').trim(),
});
event.currentTarget.reset();
const nextSummary = await fetchProductionOrders(dateRange, { search: searchTerm }, { force: true });
setSummary(nextSummary);
setDefectMessage(`Defeito registrado na OP ${order.number || `#${order.id}`}.`);
} catch (error) {
setDefectMessage(error instanceof Error ? error.message : 'Não foi possível registrar o defeito.');
} finally {
setBusyDefectOrderId(null);
}
};
const printProductionSheet = (order: ProductionOrderItem) => {
const printWindow = window.open('', '_blank', 'noopener,noreferrer');
if (!printWindow) {
setStatusMessage('Permita pop-ups para imprimir a ficha de produção.');
return;
}
const componentRows = order.components.map(component => `<tr><td>${component.componentName}</td><td>${formatQuantity(component.totalQuantity)} ${component.unit}</td></tr>`).join('') || '<tr><td colspan="2">Composição não sincronizada.</td></tr>';
printWindow.document.write(`<!doctype html><html lang="pt-BR"><head><meta charset="utf-8"><title>Ficha ${order.number || order.id}</title><style>body{font-family:Arial,sans-serif;color:#18181b;padding:32px}h1{font-size:24px;margin:0}h2{font-size:14px;text-transform:uppercase;letter-spacing:.08em;margin:28px 0 10px}.meta{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px;margin-top:20px}.box{border:1px solid #d4d4d8;border-radius:8px;padding:10px;font-size:13px}.label{display:block;color:#71717a;font-size:10px;font-weight:bold;text-transform:uppercase;margin-bottom:4px}table{border-collapse:collapse;width:100%;font-size:13px}th,td{border:1px solid #d4d4d8;padding:9px;text-align:left}th{background:#f4f4f5}.line{border-bottom:1px solid #a1a1aa;height:28px;margin-top:8px}</style></head><body><h1>Ficha de Produção · ${order.number || `OP #${order.id}`}</h1><p>${order.productDescription}</p><div class="meta"><div class="box"><span class="label">SKU</span>${order.productSku || '-'}</div><div class="box"><span class="label">Quantidade planejada</span>${formatQuantity(order.quantity)} ${order.unit}</div><div class="box"><span class="label">Status</span>${order.statusLabel}</div><div class="box"><span class="label">Referência</span>${order.orderReference || '-'}</div></div><h2>Composição prevista</h2><table><thead><tr><th>Material</th><th>Quantidade</th></tr></thead><tbody>${componentRows}</tbody></table><h2>Apontamento de execução</h2><div class="meta"><div class="box"><span class="label">Quantidade cortada</span><div class="line"></div></div><div class="box"><span class="label">Perdas / defeitos</span><div class="line"></div></div><div class="box"><span class="label">Quantidade aprovada</span><div class="line"></div></div><div class="box"><span class="label">Responsável</span><div class="line"></div></div></div><h2>Observações</h2><div class="line"></div><div class="line"></div></body></html>`);
printWindow.document.close();
printWindow.focus();
printWindow.print();
};
const handleExport = () => {
const exportData = filteredOrders.map(order => ({
'Numero': order.number,
'Pedidos': order.orderReference,
'Status': order.statusLabel,
'Data': formatDate(order.issueDate),
'Data Prevista': formatDate(order.expectedDate),
'SKU': order.productSku,
'Descricao': order.productDescription,
'Quantidade': formatQuantity(order.quantity),
'Unidade': order.unit,
'Marcadores': order.markers.map(marker => marker.label).join('; '),
'Integracao': order.integrationStatus
}));
exportToCSV(exportData, `ordens_producao_${new Date().toISOString().split('T')[0]}.csv`);
};
const renderOrderDetails = (order: ProductionOrderItem) => {
const canRecordOperationalData = ['cutting', 'sewing', 'review'].includes(order.status);
return (
<div className="space-y-6">
<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 === '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>
{order.status === 'finished' && <section><h3 className="text-xs font-bold uppercase tracking-widest text-dark-muted">Resultado registrado</h3><div className="mt-3 grid gap-3 sm:grid-cols-3"><div className="rounded-xl border border-dark-border bg-dark-card p-3"><p className="text-[10px] font-bold uppercase tracking-wider text-dark-muted">Cortado</p><p className="mt-1 text-lg font-bold text-dark-text">{formatQuantity(order.actualCutQuantity ?? 0)} <span className="text-xs text-dark-muted">{order.unit}</span></p></div><div className="rounded-xl border border-dark-border bg-dark-card p-3"><p className="text-[10px] font-bold uppercase tracking-wider text-dark-muted">Perdas</p><p className="mt-1 text-lg font-bold text-amber-200">{formatQuantity(order.rejectedQuantity || 0)} <span className="text-xs text-dark-muted">{order.unit}</span></p></div><div className="rounded-xl border border-dark-border bg-dark-card p-3"><p className="text-[10px] font-bold uppercase tracking-wider text-dark-muted">Aprovado</p><p className="mt-1 text-lg font-bold text-emerald-300">{formatQuantity(order.finishedQuantity ?? 0)} <span className="text-xs text-dark-muted">{order.unit}</span></p></div></div>{(order.markerReference || order.qualityNotes) && <div className="mt-3 rounded-xl border border-dark-border bg-dark-card p-3 text-xs font-semibold text-dark-muted">{order.markerReference && <p>Referência do risco: <span className="font-bold text-dark-text">{order.markerReference}</span></p>}{order.qualityNotes && <p className={order.markerReference ? 'mt-2' : ''}>{order.qualityNotes}</p>}</div>}</section>}
<section>
<h3 className="text-xs font-bold uppercase tracking-widest text-dark-muted">Produto e marcadores</h3>
<div className="mt-3 rounded-xl border border-dark-border bg-dark-card p-4">
<p className="font-mono text-xs font-bold text-dark-muted">{order.productSku || 'Sem SKU'}</p>
{order.markers.length > 0 && <div className="mt-3 flex flex-wrap gap-1.5">{order.markers.map(marker => <span key={`${order.id}-${marker.label}`} className="inline-flex items-center gap-1.5 rounded-full border border-dark-border bg-dark-input px-2.5 py-1 text-[10px] font-bold text-dark-muted"><span className="h-1.5 w-1.5 rounded-full" style={{ backgroundColor: marker.color || 'var(--color-dark-muted)' }} />{marker.label}</span>)}</div>}
<div className="mt-4 flex flex-wrap gap-x-5 gap-y-2 text-xs font-semibold text-dark-muted"><span>Pedido: {order.orderReference || '-'}</span><span>Integração: {order.integrationStatus || 'Local'}</span></div>
</div>
</section>
{canRecordOperationalData && <section>
<div><h3 className="text-xs font-bold uppercase tracking-widest text-dark-muted">Execução e qualidade</h3><p className="mt-1 text-xs font-semibold text-dark-muted">Registre o corte real, perdas e a quantidade aprovada antes de avançar a OP.</p></div>
<form onSubmit={(event) => void handleExecutionSave(event, order)} className="mt-3 rounded-xl border border-dark-border bg-dark-card p-4">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
<label className="text-xs font-bold text-dark-muted">Cortado real<input name="actualCutQuantity" inputMode="decimal" defaultValue={order.actualCutQuantity ?? ''} className="mt-1.5 h-11 w-full rounded-lg border border-dark-border bg-dark-input px-3 text-sm font-semibold text-dark-text outline-none placeholder:text-dark-muted focus:border-brand-primary" placeholder={formatQuantity(order.quantity)} /></label>
<label className="text-xs font-bold text-dark-muted">Perdas / defeitos<input name="rejectedQuantity" inputMode="decimal" defaultValue={order.rejectedQuantity || ''} className="mt-1.5 h-11 w-full rounded-lg border border-dark-border bg-dark-input px-3 text-sm font-semibold text-dark-text outline-none placeholder:text-dark-muted focus:border-brand-primary" placeholder="0" /></label>
<label className="text-xs font-bold text-dark-muted">Aprovado final<input name="finishedQuantity" inputMode="decimal" defaultValue={order.finishedQuantity ?? ''} className="mt-1.5 h-11 w-full rounded-lg border border-dark-border bg-dark-input px-3 text-sm font-semibold text-dark-text outline-none placeholder:text-dark-muted focus:border-brand-primary" placeholder="Após revisão" /></label>
<label className="text-xs font-bold text-dark-muted sm:col-span-2">Referência do risco<input name="markerReference" defaultValue={order.markerReference} className="mt-1.5 h-11 w-full rounded-lg border border-dark-border bg-dark-input px-3 text-sm font-semibold text-dark-text outline-none placeholder:text-dark-muted focus:border-brand-primary" placeholder="Código ou arquivo do risco" /></label>
<label className="text-xs font-bold text-dark-muted sm:col-span-3">Motivo de perda / observação<textarea name="qualityNotes" defaultValue={order.qualityNotes} className="mt-1.5 min-h-20 w-full rounded-lg border border-dark-border bg-dark-input px-3 py-2.5 text-sm font-semibold text-dark-text outline-none placeholder:text-dark-muted focus:border-brand-primary" placeholder="Ex.: 4 peças com mancha de tinta; 2 recuperadas na revisão." /></label>
</div>
<div className="mt-4 flex flex-wrap items-center justify-between gap-3"><p className="text-xs font-semibold text-dark-muted">Planejado: {formatQuantity(order.quantity)} {order.unit}</p><button type="submit" disabled={busyExecutionOrderId === order.id} className="inline-flex h-10 items-center gap-2 rounded-lg border border-brand-primary/35 bg-brand-primary/10 px-4 text-sm font-bold text-brand-primary transition-colors hover:bg-brand-primary/20 disabled:cursor-not-allowed disabled:opacity-50 cursor-pointer"><CheckCircle2 className="h-4 w-4" />{busyExecutionOrderId === order.id ? 'Salvando...' : 'Salvar execução'}</button></div>
{executionMessage && <p className="mt-3 rounded-lg border border-dark-border bg-dark-input px-3 py-2 text-sm font-bold text-dark-muted">{executionMessage}</p>}
</form>
</section>}
{(canRecordOperationalData || order.defects.length > 0) && <section>
<div>
<h3 className="text-xs font-bold uppercase tracking-widest text-dark-muted">Defeitos e origem</h3>
<p className="mt-1 text-xs font-semibold text-dark-muted">Registre cada ocorrência para acompanhar perdas por posto, lote e tipo de defeito.</p>
</div>
{canRecordOperationalData && <form onSubmit={(event) => void handleDefectSave(event, order)} className="mt-3 rounded-xl border border-dark-border bg-dark-card p-4">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
<label className="text-xs font-bold text-dark-muted">Posto de trabalho<select required name="workstationId" defaultValue="" className="mt-1.5 h-11 w-full rounded-lg border border-dark-border bg-dark-input px-3 text-sm font-semibold text-dark-text outline-none focus:border-brand-primary"><option value="">Selecione...</option>{workstations.map(workstation => <option key={workstation.id} value={workstation.id}>{workstation.name}</option>)}</select></label>
<label className="text-xs font-bold text-dark-muted">Tipo de defeito<input required name="defectType" className="mt-1.5 h-11 w-full rounded-lg border border-dark-border bg-dark-input px-3 text-sm font-semibold text-dark-text outline-none placeholder:text-dark-muted focus:border-brand-primary" placeholder="Ex.: furo, mancha" /></label>
<label className="text-xs font-bold text-dark-muted">Quantidade<input required name="quantity" inputMode="decimal" className="mt-1.5 h-11 w-full rounded-lg border border-dark-border bg-dark-input px-3 text-sm font-semibold text-dark-text outline-none placeholder:text-dark-muted focus:border-brand-primary" placeholder="0" /></label>
<label className="text-xs font-bold text-dark-muted">Lote (opcional)<input name="lotCode" defaultValue={order.lotCode} className="mt-1.5 h-11 w-full rounded-lg border border-dark-border bg-dark-input px-3 text-sm font-semibold text-dark-text outline-none placeholder:text-dark-muted focus:border-brand-primary" placeholder="Código do lote" /></label>
<label className="text-xs font-bold text-dark-muted sm:col-span-2">Origem (opcional)<input name="origin" className="mt-1.5 h-11 w-full rounded-lg border border-dark-border bg-dark-input px-3 text-sm font-semibold text-dark-text outline-none placeholder:text-dark-muted focus:border-brand-primary" placeholder="Ex.: recebimento, corte, costura" /></label>
<label className="text-xs font-bold text-dark-muted sm:col-span-2">Observação (opcional)<input name="notes" className="mt-1.5 h-11 w-full rounded-lg border border-dark-border bg-dark-input px-3 text-sm font-semibold text-dark-text outline-none placeholder:text-dark-muted focus:border-brand-primary" placeholder="Detalhe necessário para a revisão" /></label>
</div>
<div className="mt-4 flex flex-wrap items-center justify-between gap-3"><p className="text-xs font-semibold text-dark-muted">{workstations.length ? `${workstations.length} postos ativos disponíveis.` : 'Nenhum posto de trabalho ativo cadastrado.'}</p><button type="submit" disabled={busyDefectOrderId === order.id || !workstations.length} className="inline-flex h-10 items-center gap-2 rounded-lg border border-amber-400/35 bg-amber-400/10 px-4 text-sm font-bold text-amber-200 transition-colors hover:bg-amber-400/20 disabled:cursor-not-allowed disabled:opacity-50 cursor-pointer"><AlertTriangle className="h-4 w-4" />{busyDefectOrderId === order.id ? 'Registrando...' : 'Registrar defeito'}</button></div>
{defectMessage && <p className="mt-3 rounded-lg border border-dark-border bg-dark-input px-3 py-2 text-sm font-bold text-dark-muted">{defectMessage}</p>}
</form>}
{order.defects.length > 0 && <div className="mt-3 overflow-hidden rounded-xl border border-dark-border bg-dark-card"><div className="grid grid-cols-[minmax(0,1fr)_auto] gap-3 border-b border-dark-border bg-dark-input/40 px-4 py-2.5 text-[10px] font-bold uppercase tracking-widest text-dark-muted"><span>Ocorrência</span><span>Quantidade</span></div><div className="divide-y divide-dark-border">{order.defects.map(defect => <div key={defect.id} className="grid grid-cols-[minmax(0,1fr)_auto] gap-3 px-4 py-3"><div><p className="text-sm font-bold text-dark-text">{defect.defectType}</p><p className="mt-1 text-xs font-semibold text-dark-muted">{[defect.workstationName || 'Sem posto', defect.origin, defect.lotCode ? `Lote ${defect.lotCode}` : ''].filter(Boolean).join(' · ')}</p>{defect.notes && <p className="mt-1 text-xs font-medium text-dark-muted">{defect.notes}</p>}</div><p className="text-sm font-bold text-amber-200">{formatQuantity(defect.quantity)} {order.unit}</p></div>)}</div></div>}
{!order.defects.length && canRecordOperationalData && <p className="mt-3 rounded-xl border border-dark-border bg-dark-card px-4 py-3 text-sm font-semibold text-dark-muted">Nenhum defeito registrado nesta OP.</p>}
</section>}
<section>
<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>}
{order.status === 'cutting' && <section>
<h3 className="text-xs font-bold uppercase tracking-widest text-dark-muted">Baixa de material</h3>
{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>}
</div>
);
};
return (
<div className="space-y-6">
<div className="flex flex-col gap-4 xl:flex-row xl:items-center xl:justify-between">
<div>
<Link to="/supplies" className="mb-3 inline-flex items-center gap-2 text-sm font-bold text-dark-muted transition-colors hover:text-dark-text">
<ArrowLeft className="h-4 w-4" />
Suprimentos
</Link>
<h1 className="text-2xl font-bold text-dark-text">Ordens de Produção</h1>
<p className="mt-2 text-dark-muted font-medium">Acompanhe a fila de produção e o histórico das ordens.</p>
<div className="mt-3 flex flex-wrap gap-x-4 gap-y-2"><Link to="/production-plans" className="inline-flex items-center gap-2 text-sm font-bold text-brand-primary transition-colors hover:text-brand-primary/80"><ClipboardList className="h-4 w-4" />Ver planos de produção</Link><Link to="/production-quality" className="inline-flex items-center gap-2 text-sm font-bold text-brand-primary transition-colors hover:text-brand-primary/80"><AlertTriangle className="h-4 w-4" />Ver qualidade</Link></div>
</div>
<DateRangePicker
dateRange={dateRange}
onChange={(range) => {
setDateRange(range);
setCurrentPage(1);
}}
refreshInterval={refreshInterval}
setRefreshInterval={setRefreshInterval}
onManualRefresh={handleManualRefresh}
/>
</div>
<RefreshStatus isRefreshing={isRefreshing} />
{isLoading && !summary.orders.length ? (
<ProductionOrdersSkeleton />
) : (
<div className={isRefreshing ? 'refreshing-content space-y-6' : 'space-y-6'} aria-busy={isRefreshing}>
<div className="grid grid-cols-1 gap-4 md:grid-cols-4">
<div className="rounded-2xl border border-dark-border bg-dark-card p-5 shadow-sm">
<div className="flex items-start justify-between gap-4">
<div>
<p className="text-xs font-bold uppercase tracking-widest text-dark-muted">Total no período</p>
<p className="mt-2 text-3xl font-bold text-dark-text">{summary.counts.all}</p>
<p className="mt-1 text-xs font-semibold text-dark-muted">Ordens cadastradas</p>
</div>
<div className="rounded-xl border border-sky-400/25 bg-sky-400/10 p-3 text-sky-300">
<ClipboardList className="h-5 w-5" />
</div>
</div>
</div>
<div className="rounded-2xl border border-dark-border bg-dark-card p-5 shadow-sm">
<div className="flex items-start justify-between gap-4">
<div>
<p className="text-xs font-bold uppercase tracking-widest text-dark-muted">Em aberto</p>
<p className="mt-2 text-3xl font-bold text-amber-300">{openCount}</p>
<p className="mt-1 text-xs font-semibold text-dark-muted">Aguardando produção</p>
</div>
<div className="rounded-xl border border-amber-400/25 bg-amber-400/10 p-3 text-amber-300">
<Clock3 className="h-5 w-5" />
</div>
</div>
</div>
<div className="rounded-2xl border border-dark-border bg-dark-card p-5 shadow-sm">
<div className="flex items-start justify-between gap-4">
<div>
<p className="text-xs font-bold uppercase tracking-widest text-dark-muted">Em andamento</p>
<p className="mt-2 text-3xl font-bold text-sky-300">{progressCount}</p>
<p className="mt-1 text-xs font-semibold text-dark-muted">Em processo</p>
</div>
<div className="rounded-xl border border-sky-400/25 bg-sky-400/10 p-3 text-sky-300">
<PackageCheck className="h-5 w-5" />
</div>
</div>
</div>
<div className="rounded-2xl border border-dark-border bg-dark-card p-5 shadow-sm">
<div className="flex items-start justify-between gap-4">
<div>
<p className="text-xs font-bold uppercase tracking-widest text-dark-muted">Finalizadas</p>
<p className="mt-2 text-3xl font-bold text-emerald-300">{finishedCount}</p>
<p className="mt-1 text-xs font-semibold text-dark-muted">{formatQuantity(finishedQuantity)} un. aprovadas</p>
</div>
<div className="rounded-xl border border-emerald-400/25 bg-emerald-400/10 p-3 text-emerald-300">
<CheckCircle2 className="h-5 w-5" />
</div>
</div>
</div>
</div>
{summary.orders.length === 0 ? (
<div className="rounded-2xl border border-dark-border bg-dark-card p-6 shadow-sm">
<div className="mx-auto max-w-3xl text-center">
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-2xl border border-brand-primary/30 bg-brand-primary/10 text-brand-primary">
<ClipboardList className="h-6 w-6" />
</div>
<h2 className="mt-4 text-lg font-bold text-dark-text">Nenhuma ordem de produção neste período</h2>
<p className="mt-2 text-sm font-semibold leading-6 text-dark-muted">As OPs só são criadas depois que o Plano de Corte confirma SKUs com material e dados prontos para produção.</p>
</div>
<div className="mx-auto mt-6 grid max-w-4xl grid-cols-1 gap-3 md:grid-cols-3">
{[
['01', 'Resolver bloqueios', 'Confirme saldos de material e dados de composição pendentes.'],
['02', 'Definir o corte', 'Escolha SKUs liberados e revise a prévia das OPs.'],
['03', 'Acompanhar produção', 'Gere as OPs aqui e registre o consumo de material.'],
].map(([number, title, description]) => (
<div key={number} className="rounded-xl border border-dark-border bg-dark-input/45 p-4 text-left">
<span className="text-xs font-bold tracking-[0.16em] text-dark-muted">{number}</span>
<h3 className="mt-3 text-sm font-bold text-dark-text">{title}</h3>
<p className="mt-1 text-xs font-semibold leading-5 text-dark-muted">{description}</p>
</div>
))}
</div>
<div className="mt-6 flex justify-center">
<Link to="/cutting" 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">
<ClipboardList className="h-4 w-4" />
Abrir Plano de Corte
</Link>
</div>
</div>
) : (
<>
<div className="overflow-hidden rounded-2xl border border-dark-border bg-dark-card shadow-sm">
<div className="border-b border-dark-border p-5">
{statusMessage && (
<div className="mb-4 rounded-xl border border-dark-border bg-dark-input px-3 py-2 text-sm font-bold text-dark-muted">
{statusMessage}
</div>
)}
<div className="flex flex-col gap-4 xl:flex-row xl:items-center xl:justify-between">
<div className="relative w-full xl:max-w-xl">
<Search className="absolute left-3 top-1/2 h-5 w-5 -translate-y-1/2 text-dark-muted" />
<input
type="text"
value={searchTerm}
placeholder="Pesquise pelo produto, SKU ou número da ordem"
onChange={(event) => {
setSearchTerm(event.target.value);
setCurrentPage(1);
}}
className="w-full rounded-xl border border-dark-border bg-dark-input py-2.5 pl-10 pr-4 text-sm font-semibold text-dark-text shadow-sm transition-colors placeholder:text-dark-muted focus:border-brand-primary focus:outline-none"
/>
</div>
<div className="flex flex-wrap gap-3">
<button
type="button"
onClick={handleExport}
disabled={!filteredOrders.length}
className="inline-flex h-10 items-center gap-2 rounded-xl border border-dark-border bg-dark-input px-4 text-sm font-bold text-dark-text transition-colors hover:border-brand-primary disabled:cursor-not-allowed disabled:opacity-50"
>
<Download className="h-4 w-4 text-brand-primary" />
Exportar
</button>
</div>
</div>
<div className="mt-5 flex gap-5 overflow-x-auto border-b border-dark-border/70 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
{statusTabs.map(tab => {
const isActive = statusFilter === tab.key;
const count = summary.counts[tab.key] || 0;
return (
<button
key={tab.key}
type="button"
onClick={() => {
setStatusFilter(tab.key);
setCurrentPage(1);
}}
className={`relative flex min-w-24 cursor-pointer flex-col items-start pb-3 text-left transition-colors ${
isActive ? 'text-dark-text' : 'text-dark-muted hover:text-dark-text'
}`}
>
<span className="flex items-center gap-2 text-xs font-bold">
{tab.key !== 'all' && <span className={`h-1.5 w-1.5 rounded-full ${tab.dotClass}`} />}
{tab.label}
</span>
<span className="mt-1 text-sm font-bold">{count}</span>
{isActive && <span className="absolute bottom-0 left-0 right-0 h-0.5 rounded-full bg-brand-primary" />}
</button>
);
})}
</div>
</div>
<div className="overflow-x-auto">
<table className="w-full min-w-[820px] text-left text-sm">
<thead className="border-b border-dark-border bg-dark-header text-dark-muted">
<tr>
<th className="px-6 py-4 text-[10px] font-bold uppercase tracking-wider">Número</th>
<th className="px-6 py-4 text-[10px] font-bold uppercase tracking-wider">Produto</th>
<th className="px-6 py-4 text-right text-[10px] font-bold uppercase tracking-wider">Quantidade</th>
<th className="px-6 py-4 text-[10px] font-bold uppercase tracking-wider">Status</th>
<th className="px-6 py-4 text-right text-[10px] font-bold uppercase tracking-wider">Ações</th>
</tr>
</thead>
<tbody className="divide-y divide-dark-border">
{paginatedOrders.map((order: ProductionOrderItem) => {
const statusStyle = getStatusStyle(order.status, order.statusLabel);
return (
<tr key={order.id} className="transition-colors hover:bg-dark-input/50">
<td className="px-6 py-3 font-mono text-xs font-bold text-dark-text">{order.number || '-'}</td>
<td className="px-6 py-3">
<div className="font-bold text-dark-text">{order.productDescription}</div>
<div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-[10px] font-semibold text-dark-muted">
<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>
<td className="px-6 py-3 text-right">
<span className="font-bold text-dark-text">{formatQuantity(order.quantity)}</span>
<span className="ml-1 text-xs font-semibold text-dark-muted">{order.unit}</span>
</td>
<td className="px-6 py-3">
<span className={`inline-flex min-h-9 items-center gap-2 rounded-lg border px-3 text-xs font-bold ${statusStyle.className}`}>
<span className={`h-2 w-2 rounded-full ${statusStyle.dotClass}`} />
{statusStyle.label}
</span>
</td>
<td className="px-6 py-3 text-right">
<button
type="button"
onClick={() => {
setSelectedOrderId(order.id);
setSupplyMessage('');
setExitForm({ lotId: '', quantity: '', reason: '' });
}}
title="Abrir ficha da OP"
aria-label={`Abrir ficha da OP ${order.number || order.id}`}
className="inline-flex h-9 items-center justify-center gap-2 rounded-lg border border-dark-border bg-dark-input px-3 text-xs font-bold text-dark-muted transition-colors hover:border-brand-primary hover:text-brand-primary cursor-pointer"
>
Abrir OP
<ChevronDown className="h-4 w-4" />
</button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
{!filteredOrders.length && (
<div className="flex min-h-64 flex-col items-center justify-center border-t border-dark-border px-6 py-10 text-center">
<div className="rounded-2xl border border-dark-border bg-dark-input p-4 text-brand-primary">
<ClipboardList className="h-7 w-7" />
</div>
<p className="mt-4 text-sm font-bold text-dark-text">Nenhuma ordem encontrada neste filtro.</p>
<p className="mt-1 max-w-md text-sm font-medium text-dark-muted">
Ajuste a busca ou o status para ver outra parte da fila de produção.
</p>
</div>
)}
{totalPages > 1 && <PaginationControls
totalItems={filteredOrders.length}
currentPage={safeCurrentPage}
totalPages={totalPages}
pageSize={itemsPerPage}
pageSizeOptions={[10, 20, 50, 100]}
itemLabel="ordens"
pageSizeLabel="ordens por página"
startIndex={startIndex}
endIndex={Math.min(startIndex + itemsPerPage, filteredOrders.length)}
onPageChange={setCurrentPage}
onPageSizeChange={(pageSize) => {
setItemsPerPage(pageSize);
setCurrentPage(1);
}}
/>}
</div>
</>
)}
</div>
)}
{selectedOrder && <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-4 backdrop-blur-sm" role="dialog" aria-modal="true" aria-label={`Ficha da OP ${selectedOrder.number || selectedOrder.id}`} onClick={() => busyStatusOrderId !== selectedOrder.id && setSelectedOrderId(null)}>
<section className="flex max-h-[calc(100vh-2rem)] w-full max-w-5xl flex-col overflow-hidden rounded-2xl border border-dark-border bg-dark-card shadow-2xl" onClick={(event) => event.stopPropagation()}>
<header className="flex items-start justify-between gap-4 border-b border-dark-border px-5 py-4">
<div className="min-w-0"><div className="flex flex-wrap items-center gap-2"><span className="font-mono text-xs font-bold text-brand-primary">{selectedOrder.number || `OP #${selectedOrder.id}`}</span><span className={`inline-flex items-center gap-2 rounded-full border px-2.5 py-1 text-[10px] font-bold ${getStatusStyle(selectedOrder.status, selectedOrder.statusLabel).className}`}><span className={`h-1.5 w-1.5 rounded-full ${getStatusStyle(selectedOrder.status, selectedOrder.statusLabel).dotClass}`} />{getStatusStyle(selectedOrder.status, selectedOrder.statusLabel).label}</span></div><h2 className="mt-2 truncate text-lg font-bold text-dark-text" title={selectedOrder.productDescription}>{selectedOrder.productDescription}</h2><div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs font-semibold text-dark-muted"><span className="font-mono">{selectedOrder.productSku || 'Sem SKU'}</span>{selectedOrder.productionPlanNumber && <Link to="/production-plans" className="text-brand-primary hover:underline">{selectedOrder.productionPlanNumber}</Link>}<span>Previsão: {formatDate(selectedOrder.expectedDate)}</span></div></div>
<div className="flex shrink-0 items-center gap-2"><button type="button" onClick={() => printProductionSheet(selectedOrder)} title="Imprimir ficha" aria-label="Imprimir ficha" className="inline-flex h-9 w-9 items-center justify-center rounded-xl border border-dark-border bg-dark-input text-dark-muted transition-colors hover:border-brand-primary hover:text-brand-primary cursor-pointer"><Printer className="h-4 w-4" /></button><button type="button" onClick={() => setSelectedOrderId(null)} disabled={busyStatusOrderId === selectedOrder.id} title="Fechar" aria-label="Fechar ficha da OP" className="inline-flex h-9 w-9 items-center justify-center rounded-xl border border-dark-border bg-dark-input text-dark-muted transition-colors hover:border-brand-primary hover:text-dark-text disabled:cursor-not-allowed disabled:opacity-60 cursor-pointer"><X className="h-4 w-4" /></button></div>
</header>
<div className="overflow-y-auto p-5">
{statusMessage && <div className="mb-4 rounded-xl border border-dark-border bg-dark-input px-3 py-2 text-sm font-bold text-dark-muted">{statusMessage}</div>}
{selectedOrderNextStage || selectedOrderPreviousStage ? <section className="flex flex-col gap-3 rounded-xl border border-brand-primary/25 bg-brand-primary/[0.055] p-4 sm:flex-row sm:items-center sm:justify-between"><div><p className="text-[10px] font-bold uppercase tracking-wider text-brand-primary">Fluxo de produção</p><p className="mt-1 text-sm font-bold text-dark-text">{selectedOrderNextStage?.label || selectedOrderPreviousStage?.label}</p><p className="mt-1 text-xs font-semibold text-dark-muted">{selectedOrderNextStage?.description || selectedOrderPreviousStage?.description}</p></div><div className="flex shrink-0 flex-wrap gap-2"><button type="button" onClick={() => selectedOrderPreviousStage && void handleStatusChange(selectedOrder, selectedOrderPreviousStage.status)} disabled={!selectedOrderPreviousStage || busyStatusOrderId === selectedOrder.id} className="inline-flex h-10 items-center justify-center gap-2 rounded-xl border border-dark-border bg-dark-input px-4 text-sm font-bold text-dark-text transition-colors hover:border-brand-primary disabled:cursor-not-allowed disabled:opacity-60 cursor-pointer"><Undo2 className="h-4 w-4" />{busyStatusOrderId === selectedOrder.id ? 'Atualizando...' : selectedOrderPreviousStage?.label || 'Sem etapa anterior'}</button>{selectedOrderNextStage && <button type="button" onClick={() => void handleStatusChange(selectedOrder, selectedOrderNextStage.status)} disabled={busyStatusOrderId === selectedOrder.id} className="inline-flex h-10 items-center justify-center gap-2 rounded-xl bg-brand-primary px-4 text-sm font-bold text-brand-contrast transition-colors hover:bg-brand-primary/90 disabled:cursor-not-allowed disabled:opacity-60 cursor-pointer">{busyStatusOrderId === selectedOrder.id ? 'Atualizando...' : selectedOrderNextStage.label}<ChevronDown className="h-4 w-4 -rotate-90" /></button>}</div></section> : <section className="rounded-xl border border-dark-border bg-dark-input/45 px-4 py-3 text-sm font-semibold text-dark-muted">Esta OP foi cancelada e não possui próxima etapa operacional.</section>}
<div className="mt-5">{renderOrderDetails(selectedOrder)}</div>
</div>
<footer className="flex flex-col-reverse gap-2 border-t border-dark-border px-5 py-4 sm:flex-row sm:items-center sm:justify-between"><div>{selectedOrderCanReturnToCutPlan && <button type="button" onClick={() => void returnOrderToCutPlan(selectedOrder)} disabled={busyStatusOrderId === selectedOrder.id} className="inline-flex h-10 items-center gap-2 rounded-xl border border-dark-border bg-dark-input px-3 text-sm font-bold text-dark-muted transition-colors hover:border-amber-400/45 hover:text-amber-200 disabled:cursor-not-allowed disabled:opacity-60 cursor-pointer"><Undo2 className="h-4 w-4" />Devolver ao Plano de Corte</button>}</div><button type="button" onClick={() => setSelectedOrderId(null)} disabled={busyStatusOrderId === selectedOrder.id} className="h-10 rounded-xl border border-dark-border bg-dark-input px-4 text-sm font-bold text-dark-text transition-colors hover:border-brand-primary disabled:cursor-not-allowed disabled:opacity-60 cursor-pointer">Fechar</button></footer>
</section>
</div>}
</div>
);
};
export default ProductionOrders;

View File

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

View File

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More